1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-11 03:34:35 +00:00

Compare commits

...

169 Commits

Author SHA1 Message Date
Sabir Hassan
6690cafbf7 Merge c43afabe28 into 0838b8112e 2024-10-29 10:35:32 +00:00
semantic-release-bot
0838b8112e chore(release): 0.36.0 [skip ci]
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)

### Features

* **code:** added code/trigger API endpoint ([ffcf193](ffcf193b87))
2024-10-29 10:32:01 +00:00
Yury Shkoda
441f8b7726 Merge pull request #374 from sasjs/issue-373
feat(code): added code/trigger API endpoint
2024-10-29 13:29:08 +03:00
Yury
049a7f4b80 chore(swagger): improved description 2024-10-29 12:02:26 +03:00
Yury
3053c68bdf chore(lint): fixed linting issues 2024-10-29 11:40:44 +03:00
Yury
76750e864d chore(lint): fixed lint issue 2024-10-29 11:30:05 +03:00
Yury
ffcf193b87 feat(code): added code/trigger API endpoint 2024-10-29 11:18:04 +03:00
semantic-release-bot
aa2a1cbe13 chore(release): 0.35.4 [skip ci]
## [0.35.4](https://github.com/sasjs/server/compare/v0.35.3...v0.35.4) (2024-01-15)

### Bug Fixes

* **api:** fixed env issue in MacOS executable ([73d965d](73d965daf5))
2024-01-15 13:21:15 +00:00
Yury Shkoda
6f2c53555c Merge pull request #372 from sasjs/issue-371
fix(api): fixed env issue in MacOS executable
2024-01-15 16:18:10 +03:00
Yury
73d965daf5 fix(api): fixed env issue in MacOS executable 2024-01-15 15:14:06 +03:00
semantic-release-bot
4f1763db67 chore(release): 0.35.3 [skip ci]
## [0.35.3](https://github.com/sasjs/server/compare/v0.35.2...v0.35.3) (2023-11-07)

### Bug Fixes

* enable embedded LFs in JS STP vars ([7e8cbbf](7e8cbbf377))
2023-11-07 20:48:28 +00:00
Allan Bowe
28222add04 Merge pull request #370 from sasjs/allanbowe-patch-1
fix: enable embedded LFs in JS STP vars
2023-11-07 20:43:16 +00:00
Allan
068edfd6a5 chore: lint fix 2023-11-07 20:39:05 +00:00
Allan Bowe
7e8cbbf377 fix: enable embedded LFs in JS STP vars 2023-11-07 15:51:32 +00:00
c43afabe28 chore: remove unused code 2023-08-08 15:07:00 +05:00
1531e9cd9c chore: addressed comments 2023-08-08 15:01:32 +05:00
Allan Bowe
1fc1431442 chore: using GITHUB_TOKEN 2023-08-07 20:11:40 +01:00
semantic-release-bot
3387efbb9a chore(release): 0.35.2 [skip ci]
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)

### Bug Fixes

* add _debug as optional query param in swagger apis for GET stp/execute ([9586dbb](9586dbb2d0))
2023-08-07 18:53:12 +00:00
Allan Bowe
e2996b495f Merge pull request #365 from sasjs/swagger-fix
fix: add _debug as optional query param in swagger apis for  stp/execute
2023-08-07 19:48:28 +01:00
Allan
41c627f93a chore: lint fix 2023-08-07 19:39:02 +01:00
Allan Bowe
49f5dc7555 Update swagger.yaml 2023-08-07 19:32:29 +01:00
Allan Bowe
f6e77f99a4 Update swagger.yaml 2023-08-07 19:31:20 +01:00
Allan Bowe
b57dfa429b Update stp.ts 2023-08-07 19:30:09 +01:00
9586dbb2d0 fix: add _debug as optional query param in swagger apis for GET stp/execute 2023-08-07 22:01:52 +05:00
semantic-release-bot
a4f78ab48d chore(release): 0.35.1 [skip ci]
## [0.35.1](https://github.com/sasjs/server/compare/v0.35.0...v0.35.1) (2023-07-25)

### Bug Fixes

* **log-separator:** log separator should always wrap log ([8940f4d](8940f4dc47))
2023-07-25 06:05:23 +00:00
Yury Shkoda
2f47a2213b Merge pull request #364 from sasjs/log-separator
fix(log-separator): log separator should always wrap log
2023-07-25 09:01:36 +03:00
Yury Shkoda
0f91395fbb lint: fixed linting issues 2023-07-24 18:36:08 +03:00
Yury Shkoda
167b14fed0 docs(log-separator): left comment 2023-07-24 18:29:20 +03:00
Yury Shkoda
8940f4dc47 fix(log-separator): log separator should always wrap log 2023-07-24 18:27:21 +03:00
8cdf605006 chore: fix specs 2023-05-10 17:02:13 +05:00
3f815e9beb chore: fix specs 2023-05-10 14:35:35 +05:00
6c88eeabd2 chore: specs fixed 2023-05-09 15:21:54 +05:00
093fe90589 feat: replace ID with UID
BREAKING CHANGE: remove auto incremental ids from user, group and permissions and add a virtual uid property that returns string value of documents object id
2023-05-09 15:01:56 +05:00
semantic-release-bot
48c1ada1b6 chore(release): 0.35.0 [skip ci]
# [0.35.0](https://github.com/sasjs/server/compare/v0.34.2...v0.35.0) (2023-05-03)

### Bug Fixes

* **editor:** fixed log/webout/print tabs ([d2de9dc](d2de9dc13e))
* **execute:** added atribute indicating stp api ([e78f87f](e78f87f5c0))
* **execute:** fixed adding print output ([9aaffce](9aaffce820))
* **execution:** removed empty webout from response ([6dd2f4f](6dd2f4f876))
* **webout:** fixed adding empty webout to response payload ([31df72a](31df72ad88))

### Features

* **editor:** parse print output in response payload ([eb42683](eb42683fff))
2023-05-03 09:34:56 +00:00
Allan Bowe
0532488b55 Merge pull request #360 from sasjs/issue-354
Support print destination natively
2023-05-03 10:31:06 +01:00
Yury Shkoda
d458b5bb81 chore: cleanup 2023-05-03 10:56:17 +03:00
Yury Shkoda
958ab9cad2 chore(execution): add includePrintOutput to ExecuteFileParams 2023-05-03 10:46:21 +03:00
Yury Shkoda
78ceed13e1 docs(code): updated execute endpoint info 2023-05-02 16:01:00 +03:00
Yury Shkoda
a17814fc90 chore(stp): removed redundant argument 2023-05-02 15:53:13 +03:00
Yury Shkoda
9aaffce820 fix(execute): fixed adding print output 2023-05-02 15:49:44 +03:00
Yury Shkoda
e78f87f5c0 fix(execute): added atribute indicating stp api 2023-05-02 15:18:05 +03:00
Yury Shkoda
bd1b58086d docs: left a comment regarding payload parts 2023-05-02 12:10:17 +03:00
Yury Shkoda
9f521634d9 chore(webout): added comment 2023-05-02 11:30:55 +03:00
Yury Shkoda
a696168443 Merge branch 'main' of github.com:sasjs/server into issue-354 2023-05-02 11:17:41 +03:00
Yury Shkoda
31df72ad88 fix(webout): fixed adding empty webout to response payload 2023-05-02 11:17:12 +03:00
semantic-release-bot
d2239f75c2 chore(release): 0.34.2 [skip ci]
## [0.34.2](https://github.com/sasjs/server/compare/v0.34.1...v0.34.2) (2023-05-01)

### Bug Fixes

* use custom logic for handling sequence ids ([dba53de](dba53de646))
2023-05-01 15:32:32 +00:00
Allan Bowe
45428892cc Merge pull request #362 from sasjs/remove-mongoose-sequence
fix: use custom logic for handling sequence ids
2023-05-01 16:28:47 +01:00
ac27a9b894 chore: remove residue 2023-05-01 19:54:43 +05:00
dba53de646 fix: use custom logic for handling sequence ids 2023-05-01 19:28:51 +05:00
Yury Shkoda
eb42683fff feat(editor): parse print output in response payload 2023-05-01 08:18:49 +03:00
Yury Shkoda
d2de9dc13e fix(editor): fixed log/webout/print tabs 2023-05-01 07:28:23 +03:00
Yury Shkoda
6dd2f4f876 fix(execution): removed empty webout from response 2023-04-28 17:25:30 +03:00
Yury Shkoda
c0f38ba7c9 wip(print-output): added print output to response payload 2023-04-28 15:09:44 +03:00
semantic-release-bot
d2f011e8a9 chore(release): 0.34.1 [skip ci]
## [0.34.1](https://github.com/sasjs/server/compare/v0.34.0...v0.34.1) (2023-04-28)

### Bug Fixes

* **css:** fixed css loading ([9c5acd6](9c5acd6de3))
* **log:** fixed chunk collapsing ([64b156f](64b156f762))
2023-04-28 11:50:19 +00:00
Yury Shkoda
5215633e96 Merge pull request #358 from sasjs/css-issue-fix
Css issue fix
2023-04-28 14:46:12 +03:00
Yury Shkoda
64b156f762 fix(log): fixed chunk collapsing 2023-04-28 13:30:25 +03:00
Yury Shkoda
9c5acd6de3 fix(css): fixed css loading 2023-04-28 13:29:31 +03:00
semantic-release-bot
3e72384a63 chore(release): 0.34.0 [skip ci]
# [0.34.0](https://github.com/sasjs/server/compare/v0.33.3...v0.34.0) (2023-04-28)

### Bug Fixes

* **log:** fixed checks for errors and warnings ([02e2b06](02e2b060f9))
* **log:** fixed default runtime ([e04300a](e04300ad2a))
* **log:** fixed parsing log for different runtime ([3b1e4a1](3b1e4a128b))
* **log:** fixed scrolling issue ([56a522c](56a522c07c))
* **log:** fixed single chunk display ([8254b78](8254b78955))
* **log:** fixed single chunk scrolling ([57b7f95](57b7f954a1))
* **log:** fixed switching runtime ([c7a7399](c7a73991a7))
* **log:** fixing switching from SAS to other runtime ([c72ecc7](c72ecc7e59))

### Features

* **log:** added download chunk and entire log ([a38a9f9](a38a9f9c3d))
* **log:** added logComponent and LogTabWithIcons ([3a887de](3a887dec55))
* **log:** added parseErrorsAndWarnings utility ([7c1c1e2](7c1c1e2410))
* **log:** added time to downloaded log name ([3848bb0](3848bb0add))
* **log:** put download log icon into log tab ([777b3a5](777b3a55be))
* **log:** split large log into chunks ([75f5a3c](75f5a3c0b3))
* **log:** use improved log for SAS run time only ([7b12591](7b12591595))
2023-04-28 09:33:41 +00:00
Allan Bowe
df5d40b445 Merge pull request #351 from sasjs/issue-346
Improve SAS log
2023-04-28 10:29:13 +01:00
semantic-release-bot
c44ec35b3d chore(release): 0.33.3 [skip ci]
## [0.33.3](https://github.com/sasjs/server/compare/v0.33.2...v0.33.3) (2023-04-27)

### Bug Fixes

* use RateLimiterMemory instead of RateLimiterMongo ([6a520f5](6a520f5b26))
2023-04-27 18:01:26 +00:00
Allan Bowe
77fac663c5 Merge pull request #357 from sasjs/cosmosdb-issue
fix: use RateLimiterMemory instead of RateLimiterMongo
2023-04-27 18:56:53 +01:00
Yury Shkoda
3848bb0add feat(log): added time to downloaded log name 2023-04-27 18:53:45 +03:00
Yury Shkoda
56a522c07c fix(log): fixed scrolling issue 2023-04-27 17:53:45 +03:00
Yury Shkoda
87e9172cfc chore(log): used css module to declare classes 2023-04-27 17:52:57 +03:00
7df9588e66 chore: fixed specs 2023-04-27 16:26:43 +05:00
6a520f5b26 fix: use RateLimiterMemory instead of RateLimiterMongo 2023-04-27 15:06:24 +05:00
Yury Shkoda
777b3a55be feat(log): put download log icon into log tab 2023-04-26 16:10:04 +03:00
semantic-release-bot
70c3834022 chore(release): 0.33.2 [skip ci]
## [0.33.2](https://github.com/sasjs/server/compare/v0.33.1...v0.33.2) (2023-04-24)

### Bug Fixes

* removing print redirection pending full [#274](https://github.com/sasjs/server/issues/274) fix ([d49ea47](d49ea47bd7))
2023-04-24 21:13:55 +00:00
Allan Bowe
dbf6c7de08 Merge pull request #355 from sasjs/issue274
fix: removing print redirection pending full #274 fix
2023-04-24 21:59:41 +01:00
allan
d49ea47bd7 fix: removing print redirection pending full #274 fix 2023-04-24 21:58:13 +01:00
Yury Shkoda
a38a9f9c3d feat(log): added download chunk and entire log 2023-04-21 17:21:09 +03:00
semantic-release-bot
be4951d112 chore(release): 0.33.1 [skip ci]
## [0.33.1](https://github.com/sasjs/server/compare/v0.33.0...v0.33.1) (2023-04-20)

### Bug Fixes

* applying nologo only for sas.exe ([b4436ba](b4436bad0d)), closes [#352](https://github.com/sasjs/server/issues/352)
2023-04-20 08:26:33 +00:00
Allan Bowe
c116b263d9 Merge pull request #353 from sasjs/issue352
fix: applying nologo only for sas.exe
2023-04-20 09:22:29 +01:00
allan
b4436bad0d fix: applying nologo only for sas.exe
Closes #352
2023-04-20 09:16:22 +01:00
Yury Shkoda
57b7f954a1 fix(log): fixed single chunk scrolling 2023-04-18 16:16:58 +03:00
Yury Shkoda
8254b78955 fix(log): fixed single chunk display 2023-04-18 15:46:53 +03:00
Yury Shkoda
75f5a3c0b3 feat(log): split large log into chunks 2023-04-18 11:42:10 +03:00
Yury Shkoda
c72ecc7e59 fix(log): fixing switching from SAS to other runtime 2023-04-11 16:52:36 +03:00
Yury Shkoda
e04300ad2a fix(log): fixed default runtime 2023-04-11 16:42:24 +03:00
Yury Shkoda
c7a73991a7 fix(log): fixed switching runtime 2023-04-11 16:10:52 +03:00
Yury Shkoda
02e2b060f9 fix(log): fixed checks for errors and warnings 2023-04-11 15:21:46 +03:00
Yury Shkoda
3b1e4a128b fix(log): fixed parsing log for different runtime 2023-04-11 14:45:38 +03:00
Yury Shkoda
7b12591595 feat(log): use improved log for SAS run time only 2023-04-11 14:18:42 +03:00
Yury Shkoda
3a887dec55 feat(log): added logComponent and LogTabWithIcons 2023-04-10 16:21:32 +03:00
Yury Shkoda
7c1c1e2410 feat(log): added parseErrorsAndWarnings utility 2023-04-10 15:45:54 +03:00
Yury Shkoda
15774eca34 chore(deps): added react-highlight 2023-04-10 15:40:27 +03:00
semantic-release-bot
5e325522f4 chore(release): 0.33.0 [skip ci]
# [0.33.0](https://github.com/sasjs/server/compare/v0.32.0...v0.33.0) (2023-04-05)

### Features

* option to reset admin password on startup ([eda8e56](eda8e56bb0))
2023-04-05 22:07:50 +00:00
Allan Bowe
e576fad8f4 Merge pull request #350 from sasjs/issue-348
feat: option to reset admin password on startup
2023-04-05 23:03:21 +01:00
eda8e56bb0 feat: option to reset admin password on startup 2023-04-05 23:05:38 +05:00
semantic-release-bot
bee4f215d2 chore(release): 0.32.0 [skip ci]
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)

### Features

* add an api endpoint for admin to get list of client ids ([6ffaa7e](6ffaa7e9e2))
2023-04-05 09:44:13 +00:00
Allan Bowe
100f138f98 Merge pull request #349 from sasjs/issue-347
feat: add an api endpoint for admin to get list of client ids
2023-04-05 10:39:01 +01:00
6ffaa7e9e2 feat: add an api endpoint for admin to get list of client ids 2023-04-04 23:57:01 +05:00
semantic-release-bot
a433786011 chore(release): 0.31.0 [skip ci]
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)

### Features

* prevent brute force attack by rate limiting login endpoint ([a82cabb](a82cabb001))
2023-03-30 15:34:12 +00:00
Allan Bowe
1adff9a783 Merge pull request #345 from sasjs/issue-344
feat: prevent brute force attack against authorization
2023-03-30 16:29:15 +01:00
1435e380be chore: put comments on top of example in readme and .env.example 2023-03-30 15:35:16 +05:00
e099f2e678 chore: put comments on top of example in readme and .env.example 2023-03-30 15:34:50 +05:00
ddd155ba01 chore: combine scattered errors into a single object 2023-03-30 14:58:54 +05:00
9936241815 chore: fix failing specs 2023-03-29 23:46:25 +05:00
570995e572 chore: quick fix 2023-03-29 23:22:32 +05:00
462829fd9a chore: remove unused function 2023-03-29 22:10:16 +05:00
c1c0554de2 chore: quick fix 2023-03-29 22:05:29 +05:00
bd3aff9a7b chore: move secondsToHms to @sasjs/utils 2023-03-29 20:10:55 +05:00
a1e255e0c7 chore: removed unused file 2023-03-29 15:39:05 +05:00
0dae034f17 chore: revert change in package.json 2023-03-29 15:35:40 +05:00
89048ce943 chore: move brute force protection logic to middleware and a singleton class 2023-03-29 15:33:32 +05:00
a82cabb001 feat: prevent brute force attack by rate limiting login endpoint 2023-03-28 21:43:10 +05:00
c4066d32a0 chore: npm audit fix 2023-03-27 16:23:54 +05:00
semantic-release-bot
6a44cd69d9 chore(release): 0.30.3 [skip ci]
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)

### Bug Fixes

* add location.pathname to location.origin conditionally ([edab51c](edab51c519))
2023-03-07 10:45:49 +00:00
Allan Bowe
e607115995 Merge pull request #343 from sasjs/quick-fix
fix: add location.pathname to location.origin conditionally
2023-03-07 10:42:07 +00:00
edab51c519 fix: add location.pathname to location.origin conditionally 2023-03-07 15:37:22 +05:00
semantic-release-bot
081cc3102c chore(release): 0.30.2 [skip ci]
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)

### Bug Fixes

* **web:** add path to base in launch program url ([2c31922](2c31922f58))
2023-03-07 09:40:13 +00:00
Allan Bowe
b19aa1eba4 Merge pull request #342 from sasjs/quick-fix
fix(web): add path to base in launch program url
2023-03-07 09:35:09 +00:00
2c31922f58 fix(web): add path to base in launch program url 2023-03-07 09:05:29 +05:00
semantic-release-bot
4d7a571a6e chore(release): 0.30.1 [skip ci]
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)

### Bug Fixes

* **web:** add proper base url in axios.defaults ([5e3ce8a](5e3ce8a98f))
2023-03-01 18:38:43 +00:00
Allan Bowe
a373a4eb5f Merge pull request #341 from sasjs/base-url
fix(web): add proper base url in axios.defaults
2023-03-01 18:34:55 +00:00
5e3ce8a98f fix(web): add proper base url in axios.defaults 2023-03-01 21:45:18 +05:00
semantic-release-bot
737b34567e chore(release): 0.30.0 [skip ci]
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)

### Bug Fixes

* lint + remove default settings ([3de59ac](3de59ac4f8))

### Features

* add new env config DB_TYPE ([158f044](158f044363))
2023-02-28 21:08:30 +00:00
Allan Bowe
6373442f83 Merge pull request #340 from sasjs/issue-339
feat: add new env config DB_TYPE
2023-02-28 21:04:25 +00:00
munja
3de59ac4f8 fix: lint + remove default settings 2023-02-28 21:01:39 +00:00
Allan Bowe
941988cd7c chore(docs): linking to official docs 2023-02-28 20:55:32 +00:00
158f044363 feat: add new env config DB_TYPE 2023-03-01 01:41:08 +05:00
semantic-release-bot
02ae041a81 chore(release): 0.29.0 [skip ci]
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)

### Features

* Add /SASjsApi endpoint in permissions ([b3402ea](b3402ea80a))
2023-02-06 13:07:06 +00:00
Allan Bowe
c4c84b1537 Merge pull request #338 from sasjs/issue-224
feat: Add /SASjsApi endpoint in permissions
2023-02-06 13:02:49 +00:00
b3402ea80a feat: Add /SASjsApi endpoint in permissions 2023-02-06 15:29:24 +05:00
semantic-release-bot
abe942e697 chore(release): 0.28.7 [skip ci]
## [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](2bae52e307))
2023-02-03 13:48:40 +00:00
Allan Bowe
faf2edb111 Merge pull request #337 from sasjs/issue-336
fix: add user to all users group on user creation
2023-02-03 13:44:46 +00:00
5bec453e89 chore: quick fix 2023-02-03 18:39:35 +05:00
7f2174dd2c chore: quick fix 2023-02-03 16:48:18 +05:00
2bae52e307 fix: add user to all users group on user creation 2023-02-03 16:47:18 +05:00
semantic-release-bot
b243e62ece chore(release): 0.28.6 [skip ci]
## [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](69f2576ee6))
2023-01-26 18:20:28 +00:00
Sabir Hassan
88c3056e97 Merge pull request #335 from sasjs/issue-330
fix: show loading spinner on login screen while request is in process
2023-01-26 23:16:25 +05:00
203303b659 chore: bump the version of mongodb-memory-server 2023-01-26 23:12:46 +05:00
835709bd36 chore: npm audit fix 2023-01-26 23:10:20 +05:00
69f2576ee6 fix: show loading spinner on login screen while request is in process 2023-01-26 22:27:43 +05:00
semantic-release-bot
305077f36e chore(release): 0.28.5 [skip ci]
## [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](96eca3a35d))
2023-01-01 16:55:09 +00:00
Allan Bowe
96eca3a35d fix: adding NOPRNGETLIST system option for faster startup 2023-01-01 16:49:48 +00:00
semantic-release-bot
0f5c815c25 chore(release): 0.28.4 [skip ci]
## [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](71c429b093))
2022-12-07 16:08:32 +00:00
Allan Bowe
acccef1e99 Merge pull request #334 from sasjs/issue-332
fix: Studio Editor autocomplete invisible
2022-12-07 16:04:42 +00:00
abc34ea047 chore: npm audit fix 2022-12-07 20:26:31 +05:00
71c429b093 fix: replace main class with container class 2022-12-07 20:25:06 +05:00
semantic-release-bot
c126f2d5d9 chore(release): 0.28.3 [skip ci]
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)

### Bug Fixes

* stringify json file ([1192583](1192583843))
2022-12-06 14:17:01 +00:00
Allan Bowe
34dd95d16e Merge pull request #333 from sasjs/issue-331
fix: stringify json file
2022-12-06 14:11:37 +00:00
1192583843 fix: stringify json file 2022-12-06 18:55:01 +05:00
semantic-release-bot
518815acf1 chore(release): 0.28.2 [skip ci]
## [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](23c997b3be))
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](39ba995355))
2022-12-05 16:25:38 +00:00
Allan Bowe
80b7e14ed5 Merge pull request #329 from sasjs/issue-326
fix: non sas programs shouldn't use sas session folder
2022-12-05 16:21:58 +00:00
23c997b3be fix: execute child process asyncronously 2022-12-01 23:27:40 +05:00
39ba995355 fix: JS / Python / R session folders should be NEW folders, not existing SAS folders 2022-12-01 23:26:30 +05:00
semantic-release-bot
0e081e024b chore(release): 0.28.1 [skip ci]
## [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](4dcee4b3c3))
2022-11-28 23:25:10 +00:00
Allan Bowe
6a84bd0387 Merge pull request #327 from sasjs/issue-325
fix: default response header fixed when debug is ON
2022-11-28 23:20:30 +00:00
98d177a691 chore: audit fix 2022-11-28 23:55:21 +05:00
4dcee4b3c3 fix: update the content type header after the program has been executed 2022-11-28 23:53:36 +05:00
semantic-release-bot
4ffc1ec6a9 chore(release): 0.28.0 [skip ci]
# [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](112431a1b7))

### Features

* **api:** add the api endpoint for updating user password ([4581f32](4581f32534))
* ask for updated password on first login ([1d48f88](1d48f8856b))
* **web:** add the UI for updating user password ([8b8c43c](8b8c43c21b))
2022-11-28 17:43:05 +00:00
Allan Bowe
5a1d168e83 Merge pull request #324 from sasjs/issue-322
fix: update the response header of request to stp/execute routes
2022-11-28 17:38:05 +00:00
Allan Bowe
515c976685 Merge pull request #323 from sasjs/issue-222
feat: force user to change password on first login
2022-11-28 17:37:17 +00:00
112431a1b7 fix: update the response header of request to stp/execute routes 2022-11-27 21:57:26 +05:00
c26485afec chore: fix specs 2022-11-22 20:15:26 +05:00
1d48f8856b feat: ask for updated password on first login 2022-11-22 19:58:17 +05:00
68758aa616 chore: new password should be different to current password 2022-11-22 15:26:22 +05:00
8b8c43c21b feat(web): add the UI for updating user password 2022-11-22 00:03:25 +05:00
4581f32534 feat(api): add the api endpoint for updating user password 2022-11-22 00:02:59 +05:00
b47e74a7e1 chore: styles fix 2022-11-22 00:01:58 +05:00
b27d684145 chore: use process.logger instead of condole.log 2022-11-17 23:03:33 +05:00
semantic-release-bot
6b666d5554 chore(release): 0.27.0 [skip ci]
# [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](200f6c596a))
2022-11-17 13:21:44 +00:00
Allan Bowe
b5f0911858 Merge pull request #321 from sasjs/issue-318
feat: on startup add webout.sas file in sasautos folder
2022-11-17 13:17:35 +00:00
b86ba5b8a3 chore: lint fix 2022-11-17 17:49:00 +05:00
200f6c596a feat: on startup add webout.sas file in sasautos folder 2022-11-17 17:03:23 +05:00
semantic-release-bot
1b7ccda6e9 chore(release): 0.26.2 [skip ci]
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)

### Bug Fixes

* comments ([7ae862c](7ae862c5ce))
2022-11-15 13:06:36 +00:00
Allan Bowe
532035d835 Merge pull request #317 from sasjs/docfix
fix: comments
2022-11-15 13:01:45 +00:00
Allan Bowe
7ae862c5ce fix: comments 2022-11-15 13:01:13 +00:00
115 changed files with 7688 additions and 2015 deletions

View File

@@ -56,4 +56,4 @@ jobs:
- name: Release - name: Release
run: | run: |
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} semantic-release

View File

@@ -1,3 +1,254 @@
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)
### Features
* **code:** added code/trigger API endpoint ([ffcf193](https://github.com/sasjs/server/commit/ffcf193b87d811b166d79af74013776a253b50b0))
## [0.35.4](https://github.com/sasjs/server/compare/v0.35.3...v0.35.4) (2024-01-15)
### Bug Fixes
* **api:** fixed env issue in MacOS executable ([73d965d](https://github.com/sasjs/server/commit/73d965daf54b16c0921e4b18d11a1e6f8650884d))
## [0.35.3](https://github.com/sasjs/server/compare/v0.35.2...v0.35.3) (2023-11-07)
### Bug Fixes
* enable embedded LFs in JS STP vars ([7e8cbbf](https://github.com/sasjs/server/commit/7e8cbbf377b27a7f5dd9af0bc6605c01f302f5d9))
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)
### Bug Fixes
* add _debug as optional query param in swagger apis for GET stp/execute ([9586dbb](https://github.com/sasjs/server/commit/9586dbb2d0d6611061c9efdfb84030144f62c2ee))
## [0.35.1](https://github.com/sasjs/server/compare/v0.35.0...v0.35.1) (2023-07-25)
### Bug Fixes
* **log-separator:** log separator should always wrap log ([8940f4d](https://github.com/sasjs/server/commit/8940f4dc47abae2036b4fcdeb772c31a0ca07cca))
# [0.35.0](https://github.com/sasjs/server/compare/v0.34.2...v0.35.0) (2023-05-03)
### Bug Fixes
* **editor:** fixed log/webout/print tabs ([d2de9dc](https://github.com/sasjs/server/commit/d2de9dc13ef2e980286dd03cca5e22cea443ed0c))
* **execute:** added atribute indicating stp api ([e78f87f](https://github.com/sasjs/server/commit/e78f87f5c00038ea11261dffb525ac8f1024e40b))
* **execute:** fixed adding print output ([9aaffce](https://github.com/sasjs/server/commit/9aaffce82051d81bf39adb69942bb321e9795141))
* **execution:** removed empty webout from response ([6dd2f4f](https://github.com/sasjs/server/commit/6dd2f4f87673336135bc7a6de0d2e143e192c025))
* **webout:** fixed adding empty webout to response payload ([31df72a](https://github.com/sasjs/server/commit/31df72ad88fe2c771d0ef8445d6db9dd147c40c9))
### Features
* **editor:** parse print output in response payload ([eb42683](https://github.com/sasjs/server/commit/eb42683fff701bd5b4d2b68760fe0c3ecad573dd))
## [0.34.2](https://github.com/sasjs/server/compare/v0.34.1...v0.34.2) (2023-05-01)
### Bug Fixes
* use custom logic for handling sequence ids ([dba53de](https://github.com/sasjs/server/commit/dba53de64664c9d8a40fe69de6281c53d1c73641))
## [0.34.1](https://github.com/sasjs/server/compare/v0.34.0...v0.34.1) (2023-04-28)
### Bug Fixes
* **css:** fixed css loading ([9c5acd6](https://github.com/sasjs/server/commit/9c5acd6de32afdbc186f79ae5b35375dda2e49b0))
* **log:** fixed chunk collapsing ([64b156f](https://github.com/sasjs/server/commit/64b156f7627969b7f13022726f984fbbfe1a33ef))
# [0.34.0](https://github.com/sasjs/server/compare/v0.33.3...v0.34.0) (2023-04-28)
### Bug Fixes
* **log:** fixed checks for errors and warnings ([02e2b06](https://github.com/sasjs/server/commit/02e2b060f9bedf4806f45f5205fd87bfa2ecae90))
* **log:** fixed default runtime ([e04300a](https://github.com/sasjs/server/commit/e04300ad2ac237be7b28a6332fa87a3bcf761c7b))
* **log:** fixed parsing log for different runtime ([3b1e4a1](https://github.com/sasjs/server/commit/3b1e4a128b1f22ff6f3069f5aaada6bfb1b40d12))
* **log:** fixed scrolling issue ([56a522c](https://github.com/sasjs/server/commit/56a522c07c6f6d4c26c6d3b7cd6e9ef7007067a9))
* **log:** fixed single chunk display ([8254b78](https://github.com/sasjs/server/commit/8254b789555cb8bbb169f52b754b4ce24e876dd2))
* **log:** fixed single chunk scrolling ([57b7f95](https://github.com/sasjs/server/commit/57b7f954a17936f39aa9b757998b5b25e9442601))
* **log:** fixed switching runtime ([c7a7399](https://github.com/sasjs/server/commit/c7a73991a7aa25d0c75d0c00e712bdc78769300b))
* **log:** fixing switching from SAS to other runtime ([c72ecc7](https://github.com/sasjs/server/commit/c72ecc7e5943af9536ee31cfa85398e016d5354f))
### Features
* **log:** added download chunk and entire log ([a38a9f9](https://github.com/sasjs/server/commit/a38a9f9c3dfe36bd55d32024c166147318216995))
* **log:** added logComponent and LogTabWithIcons ([3a887de](https://github.com/sasjs/server/commit/3a887dec55371b6a00b92291bb681e4cccb770c0))
* **log:** added parseErrorsAndWarnings utility ([7c1c1e2](https://github.com/sasjs/server/commit/7c1c1e241002313c10f94dd61702584b9f148010))
* **log:** added time to downloaded log name ([3848bb0](https://github.com/sasjs/server/commit/3848bb0added69ca81a5c9419ea414bdd1c294bb))
* **log:** put download log icon into log tab ([777b3a5](https://github.com/sasjs/server/commit/777b3a55be1ecf5b05bf755ce8b14735496509e1))
* **log:** split large log into chunks ([75f5a3c](https://github.com/sasjs/server/commit/75f5a3c0b39665bef8b83dc7e1e8b3e5f23fc303))
* **log:** use improved log for SAS run time only ([7b12591](https://github.com/sasjs/server/commit/7b12591595cdd5144d9311ffa06a80c5dab79364))
## [0.33.3](https://github.com/sasjs/server/compare/v0.33.2...v0.33.3) (2023-04-27)
### Bug Fixes
* use RateLimiterMemory instead of RateLimiterMongo ([6a520f5](https://github.com/sasjs/server/commit/6a520f5b26a3e2ed6345721b30ff4e3d9bfa903d))
## [0.33.2](https://github.com/sasjs/server/compare/v0.33.1...v0.33.2) (2023-04-24)
### Bug Fixes
* removing print redirection pending full [#274](https://github.com/sasjs/server/issues/274) fix ([d49ea47](https://github.com/sasjs/server/commit/d49ea47bd7a2add42bdb9a717082201f29e16597))
## [0.33.1](https://github.com/sasjs/server/compare/v0.33.0...v0.33.1) (2023-04-20)
### Bug Fixes
* applying nologo only for sas.exe ([b4436ba](https://github.com/sasjs/server/commit/b4436bad0d24d5b5a402272632db1739b1018c90)), closes [#352](https://github.com/sasjs/server/issues/352)
# [0.33.0](https://github.com/sasjs/server/compare/v0.32.0...v0.33.0) (2023-04-05)
### Features
* option to reset admin password on startup ([eda8e56](https://github.com/sasjs/server/commit/eda8e56bb0ea20fdaacabbbe7dcf1e3ea7bd215a))
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)
### Features
* add an api endpoint for admin to get list of client ids ([6ffaa7e](https://github.com/sasjs/server/commit/6ffaa7e9e2a62c083bb9fcc3398dcbed10cebdb1))
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)
### Features
* prevent brute force attack by rate limiting login endpoint ([a82cabb](https://github.com/sasjs/server/commit/a82cabb00134c79c5ee77afd1b1628a1f768e050))
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)
### Bug Fixes
* add location.pathname to location.origin conditionally ([edab51c](https://github.com/sasjs/server/commit/edab51c51997f17553e037dc7c2b5e5fa6ea8ffe))
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)
### Bug Fixes
* **web:** add path to base in launch program url ([2c31922](https://github.com/sasjs/server/commit/2c31922f58a8aa20d7fa6bfc95b53a350f90c798))
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)
### Bug Fixes
* **web:** add proper base url in axios.defaults ([5e3ce8a](https://github.com/sasjs/server/commit/5e3ce8a98f1825e14c1d26d8da0c9821beeff7b3))
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)
### Bug Fixes
* lint + remove default settings ([3de59ac](https://github.com/sasjs/server/commit/3de59ac4f8e3d95cad31f09e6963bd04c4811f26))
### Features
* add new env config DB_TYPE ([158f044](https://github.com/sasjs/server/commit/158f044363abf2576c8248f0ca9da4bc9cb7e9d8))
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)
### Features
* Add /SASjsApi endpoint in permissions ([b3402ea](https://github.com/sasjs/server/commit/b3402ea80afb8802eee8b8b6cbbbcc29903424bc))
## [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) ## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)

View File

@@ -137,6 +137,9 @@ CA_ROOT=fullchain.pem (optional)
## ENV variables required for MODE: `server` ## ENV variables required for MODE: `server`
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# options: [mongodb|cosmos_mongodb] default: mongodb
DB_TYPE=
# AUTH_PROVIDERS options: [ldap] default: `` # AUTH_PROVIDERS options: [ldap] default: ``
AUTH_PROVIDERS= AUTH_PROVIDERS=
@@ -155,7 +158,7 @@ CORS=
WHITELIST= WHITELIST=
# HELMET Cross Origin Embedder Policy # HELMET Cross Origin Embedder Policy
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true` # Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
# options: [true|false] default: true # options: [true|false] default: true
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`) # Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
HELMET_COEP= HELMET_COEP=
@@ -172,6 +175,32 @@ HELMET_COEP=
# } # }
HELMET_CSP_CONFIG_PATH=./csp.config.json HELMET_CSP_CONFIG_PATH=./csp.config.json
# To prevent brute force attack on login route we have implemented rate limiter
# Only valid for MODE: server
# Following are configurable env variable rate limiter
# After this, access is blocked for 1 day
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
# After this, access is blocked for an hour
# Store number for 24 days since first fail
# Once a successful login is attempted, it resets
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
# Name of the admin user that will be created on startup if not exists already
# Default is `secretuser`
ADMIN_USERNAME=secretuser
# Temporary password for the ADMIN_USERNAME, which is in place until the first login
# Default is `secretpassword`
ADMIN_PASSWORD_INITIAL=secretpassword
# Specify whether app has to reset the ADMIN_USERNAME's password or not
# Default is NO. Possible options are YES and NO
# If ADMIN_PASSWORD_RESET is YES then the ADMIN_USERNAME will be prompted to change the password from ADMIN_PASSWORD_INITIAL on their next login. This will repeat on every server restart, unless the option is removed / set to NO.
ADMIN_PASSWORD_RESET=NO
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common` # LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
# Docs: https://www.npmjs.com/package/morgan#predefined-formats # Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN= LOG_FORMAT_MORGAN=

View File

@@ -14,6 +14,7 @@ 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 HELMET_COEP=[true|false] if omitted HELMET default will be used
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb
AUTH_PROVIDERS=[ldap] AUTH_PROVIDERS=[ldap]
@@ -23,6 +24,16 @@ LDAP_BIND_PASSWORD = <password>
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron> LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron> LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
#default value is 100
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
#default value is 10
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
ADMIN_USERNAME=secretuser
ADMIN_PASSWORD_INITIAL=secretpassword
ADMIN_PASSWORD_RESET=NO
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node

3414
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,7 @@
"author": "4GL Ltd", "author": "4GL Ltd",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.40.1", "@sasjs/core": "^4.40.1",
"@sasjs/utils": "2.48.1", "@sasjs/utils": "3.2.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^4.6.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@@ -61,9 +61,9 @@
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"ldapjs": "2.3.3", "ldapjs": "2.3.3",
"mongoose": "^6.0.12", "mongoose": "^6.0.12",
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4", "rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0", "swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11", "unzipper": "^0.10.11",
@@ -79,7 +79,6 @@
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
"@types/ldapjs": "^2.2.4", "@types/ldapjs": "^2.2.4",
"@types/mongoose-sequence": "^3.0.6",
"@types/morgan": "^1.9.3", "@types/morgan": "^1.9.3",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
@@ -89,10 +88,10 @@
"adm-zip": "^0.5.9", "adm-zip": "^0.5.9",
"axios": "0.27.2", "axios": "0.27.2",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^10.0.0", "dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
"jest": "^27.0.6", "jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0", "mongodb-memory-server": "8.11.4",
"nodejs-file-downloader": "4.10.2", "nodejs-file-downloader": "4.10.2",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"pkg": "5.6.0", "pkg": "5.6.0",

View File

@@ -40,13 +40,27 @@ components:
clientId: clientId:
type: string type: string
userId: userId:
type: number type: string
format: double
required: required:
- clientId - clientId
- userId - userId
type: object type: object
additionalProperties: false additionalProperties: false
UpdatePasswordPayload:
properties:
currentPassword:
type: string
description: 'Current Password'
example: currentPasswordString
newPassword:
type: string
description: 'New Password'
example: newPassword
required:
- currentPassword
- newPassword
type: object
additionalProperties: false
ClientPayload: ClientPayload:
properties: properties:
clientId: clientId:
@@ -83,17 +97,47 @@ components:
properties: properties:
code: code:
type: string type: string
description: 'Code of program' description: 'The code to be executed'
example: '* Code HERE;' example: '* Your Code HERE;'
runTime: runTime:
$ref: '#/components/schemas/RunTimeType' $ref: '#/components/schemas/RunTimeType'
description: 'runtime for program' description: 'The runtime for the code - eg SAS, JS, PY or R'
example: js example: js
required: required:
- code - code
- runTime - runTime
type: object type: object
additionalProperties: false additionalProperties: false
TriggerCodeResponse:
properties:
sessionId:
type: string
description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll job status.\nThis session ID should be used to poll job status."
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }'
required:
- sessionId
type: object
additionalProperties: false
TriggerCodePayload:
properties:
code:
type: string
description: 'The code to be executed'
example: '* Your Code HERE;'
runTime:
$ref: '#/components/schemas/RunTimeType'
description: 'The runtime for the code - eg SAS, JS, PY or R'
example: sas
expiresAfterMins:
type: number
format: double
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
example: 15
required:
- code
- runTime
type: object
additionalProperties: false
MemberType.folder: MemberType.folder:
enum: enum:
- folder - folder
@@ -270,9 +314,8 @@ components:
additionalProperties: false additionalProperties: false
UserResponse: UserResponse:
properties: properties:
id: uid:
type: number type: string
format: double
username: username:
type: string type: string
displayName: displayName:
@@ -280,7 +323,7 @@ components:
isAdmin: isAdmin:
type: boolean type: boolean
required: required:
- id - uid
- username - username
- displayName - displayName
- isAdmin - isAdmin
@@ -288,32 +331,30 @@ components:
additionalProperties: false additionalProperties: false
GroupResponse: GroupResponse:
properties: properties:
groupId: uid:
type: number type: string
format: double
name: name:
type: string type: string
description: description:
type: string type: string
required: required:
- groupId - uid
- name - name
- description - description
type: object type: object
additionalProperties: false additionalProperties: false
UserDetailsResponse: UserDetailsResponse:
properties: properties:
id: uid:
type: number
format: double
displayName:
type: string type: string
username: username:
type: string type: string
isActive: displayName:
type: boolean type: string
isAdmin: isAdmin:
type: boolean type: boolean
isActive:
type: boolean
autoExec: autoExec:
type: string type: string
groups: groups:
@@ -321,11 +362,11 @@ components:
$ref: '#/components/schemas/GroupResponse' $ref: '#/components/schemas/GroupResponse'
type: array type: array
required: required:
- id - uid
- displayName
- username - username
- isActive - displayName
- isAdmin - isAdmin
- isActive
type: object type: object
additionalProperties: false additionalProperties: false
UserPayload: UserPayload:
@@ -361,9 +402,8 @@ components:
additionalProperties: false additionalProperties: false
GroupDetailsResponse: GroupDetailsResponse:
properties: properties:
groupId: uid:
type: number type: string
format: double
name: name:
type: string type: string
description: description:
@@ -375,7 +415,7 @@ components:
$ref: '#/components/schemas/UserResponse' $ref: '#/components/schemas/UserResponse'
type: array type: array
required: required:
- groupId - uid
- name - name
- description - description
- isActive - isActive
@@ -444,9 +484,8 @@ components:
additionalProperties: false additionalProperties: false
PermissionDetailsResponse: PermissionDetailsResponse:
properties: properties:
permissionId: uid:
type: number type: string
format: double
path: path:
type: string type: string
type: type:
@@ -458,7 +497,7 @@ components:
group: group:
$ref: '#/components/schemas/GroupDetailsResponse' $ref: '#/components/schemas/GroupDetailsResponse'
required: required:
- permissionId - uid
- path - path
- type - type
- setting - setting
@@ -497,10 +536,8 @@ components:
description: 'Indicates the type of principal' description: 'Indicates the type of principal'
example: user example: user
principalId: principalId:
type: number type: string
format: double
description: 'The id of user or group to which a rule is assigned.' description: 'The id of user or group to which a rule is assigned.'
example: 123
required: required:
- path - path
- type - type
@@ -519,6 +556,39 @@ components:
- setting - setting
type: object type: object
additionalProperties: false additionalProperties: false
Pick_UserResponse.Exclude_keyofUserResponse.uid__:
properties:
username:
type: string
displayName:
type: string
isAdmin:
type: boolean
required:
- username
- displayName
- isAdmin
type: object
description: 'From T, pick a set of properties whose keys are in the union K'
SessionResponse:
properties:
username:
type: string
displayName:
type: string
isAdmin:
type: boolean
id:
type: string
needsToUpdatePassword:
type: boolean
required:
- username
- displayName
- isAdmin
- id
type: object
additionalProperties: false
ExecutePostRequestPayload: ExecutePostRequestPayload:
properties: properties:
_program: _program:
@@ -632,6 +702,25 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] parameters: []
/SASjsApi/auth/updatePassword:
patch:
operationId: UpdatePassword
responses:
'204':
description: 'No content'
summary: 'Update user''s password.'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdatePasswordPayload'
/SASjsApi/authConfig: /SASjsApi/authConfig:
get: get:
operationId: GetDetail operationId: GetDetail
@@ -689,12 +778,8 @@ paths:
$ref: '#/components/schemas/ClientPayload' $ref: '#/components/schemas/ClientPayload'
examples: examples:
'Example 1': 'Example 1':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
'Example 2':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400} value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}
'Example 3': summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiration (optional),\nrefreshTokenExpiration (optional)"
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}
summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiryDays (optional),\nrefreshTokenExpiryDays (optional)"
tags: tags:
- Client - Client
security: security:
@@ -707,6 +792,27 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ClientPayload' $ref: '#/components/schemas/ClientPayload'
get:
operationId: GetAllClients
responses:
'200':
description: Ok
content:
application/json:
schema:
items:
$ref: '#/components/schemas/ClientPayload'
type: array
examples:
'Example 1':
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
summary: 'Admin only task. Returns the list of all the clients'
tags:
- Client
security:
-
bearerAuth: []
parameters: []
/SASjsApi/code/execute: /SASjsApi/code/execute:
post: post:
operationId: ExecuteCode operationId: ExecuteCode
@@ -720,7 +826,7 @@ paths:
- {type: string} - {type: string}
- {type: string, format: byte} - {type: string, format: byte}
description: 'Execute Code on the Specified Runtime' description: 'Execute Code on the Specified Runtime'
summary: 'Run Code and Return Webout Content and Log' summary: "Run Code and Return Webout Content, Log and Print output\nThe order of returned parts of the payload is:\n1. Webout (if present)\n2. Logs UUID (used as separator)\n3. Log\n4. Logs UUID (used as separator)\n5. Print (if present and if the runtime is SAS)\nPlease see"
tags: tags:
- Code - Code
security: security:
@@ -733,6 +839,30 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecuteCodePayload' $ref: '#/components/schemas/ExecuteCodePayload'
/SASjsApi/code/trigger:
post:
operationId: TriggerCode
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerCodeResponse'
description: 'Trigger Code on the Specified Runtime'
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
tags:
- Code
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerCodePayload'
/SASjsApi/drive/deploy: /SASjsApi/drive/deploy:
post: post:
operationId: Deploy operationId: Deploy
@@ -1134,7 +1264,7 @@ paths:
type: array type: array
examples: examples:
'Example 1': 'Example 1':
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}] value: [{uid: userIdString, username: johnusername, displayName: John, isAdmin: false}, {uid: anotherUserIdString, username: starkusername, displayName: Stark, isAdmin: true}]
summary: 'Get list of all users (username, displayname). All users can request this.' summary: 'Get list of all users (username, displayname). All users can request this.'
tags: tags:
- User - User
@@ -1153,7 +1283,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse' $ref: '#/components/schemas/UserDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.' summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.'
tags: tags:
- User - User
@@ -1204,7 +1334,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse' $ref: '#/components/schemas/UserDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags: tags:
- User - User
@@ -1255,7 +1385,7 @@ paths:
password: password:
type: string type: string
type: object type: object
'/SASjsApi/user/{userId}': '/SASjsApi/user/{uid}':
get: get:
operationId: GetUser operationId: GetUser
responses: responses:
@@ -1274,14 +1404,12 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'The user''s identifier'
in: path in: path
name: userId name: uid
required: true required: true
schema: schema:
format: double type: string
type: number '/SASjsApi/user/{userId}':
example: 1234
patch: patch:
operationId: UpdateUser operationId: UpdateUser
responses: responses:
@@ -1293,7 +1421,7 @@ paths:
$ref: '#/components/schemas/UserDetailsResponse' $ref: '#/components/schemas/UserDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags: tags:
- User - User
@@ -1307,8 +1435,7 @@ paths:
name: userId name: userId
required: true required: true
schema: schema:
format: double type: string
type: number
example: '1234' example: '1234'
requestBody: requestBody:
required: true required: true
@@ -1334,8 +1461,7 @@ paths:
name: userId name: userId
required: true required: true
schema: schema:
format: double type: string
type: number
example: 1234 example: 1234
requestBody: requestBody:
required: true required: true
@@ -1360,7 +1486,7 @@ paths:
type: array type: array
examples: examples:
'Example 1': 'Example 1':
value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}] value: [{uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users'}]
summary: 'Get list of all groups (groupName and groupDescription). All users can request this.' summary: 'Get list of all groups (groupName and groupDescription). All users can request this.'
tags: tags:
- Group - Group
@@ -1379,7 +1505,7 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse' $ref: '#/components/schemas/GroupDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Create a new group. Admin only.' summary: 'Create a new group. Admin only.'
tags: tags:
- Group - Group
@@ -1395,7 +1521,7 @@ paths:
$ref: '#/components/schemas/GroupPayload' $ref: '#/components/schemas/GroupPayload'
'/SASjsApi/group/by/groupname/{name}': '/SASjsApi/group/by/groupname/{name}':
get: get:
operationId: GetGroupByGroupName operationId: GetGroupByName
responses: responses:
'200': '200':
description: Ok description: Ok
@@ -1417,7 +1543,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
'/SASjsApi/group/{groupId}': '/SASjsApi/group/{uid}':
get: get:
operationId: GetGroup operationId: GetGroup
responses: responses:
@@ -1437,12 +1563,11 @@ paths:
- -
description: 'The group''s identifier' description: 'The group''s identifier'
in: path in: path
name: groupId name: uid
required: true required: true
schema: schema:
format: double type: string
type: number example: 12ByteString
example: 1234
delete: delete:
operationId: DeleteGroup operationId: DeleteGroup
responses: responses:
@@ -1464,13 +1589,12 @@ paths:
- -
description: 'The group''s identifier' description: 'The group''s identifier'
in: path in: path
name: groupId name: uid
required: true required: true
schema: schema:
format: double type: string
type: number example: 12ByteString
example: 1234 '/SASjsApi/group/{groupUid}/{userUid}':
'/SASjsApi/group/{groupId}/{userId}':
post: post:
operationId: AddUserToGroup operationId: AddUserToGroup
responses: responses:
@@ -1482,7 +1606,7 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse' $ref: '#/components/schemas/GroupDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Add a user to a group. Admin task only.' summary: 'Add a user to a group. Admin task only.'
tags: tags:
- Group - Group
@@ -1493,21 +1617,18 @@ paths:
- -
description: 'The group''s identifier' description: 'The group''s identifier'
in: path in: path
name: groupId name: groupUid
required: true required: true
schema: schema:
format: double type: string
type: number example: 12ByteString
example: '1234'
- -
description: 'The user''s identifier' description: 'The user''s identifier'
in: path in: path
name: userId name: userUid
required: true required: true
schema: schema:
format: double type: string
type: number
example: '6789'
delete: delete:
operationId: RemoveUserFromGroup operationId: RemoveUserFromGroup
responses: responses:
@@ -1519,8 +1640,8 @@ paths:
$ref: '#/components/schemas/GroupDetailsResponse' $ref: '#/components/schemas/GroupDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
summary: 'Remove a user to a group. Admin task only.' summary: 'Remove a user from a group. Admin task only.'
tags: tags:
- Group - Group
security: security:
@@ -1530,21 +1651,19 @@ paths:
- -
description: 'The group''s identifier' description: 'The group''s identifier'
in: path in: path
name: groupId name: groupUid
required: true required: true
schema: schema:
format: double type: string
type: number example: 12ByteString
example: '1234'
- -
description: 'The user''s identifier' description: 'The user''s identifier'
in: path in: path
name: userId name: userUid
required: true required: true
schema: schema:
format: double type: string
type: number example: 12ByteString
example: '6789'
/SASjsApi/info: /SASjsApi/info:
get: get:
operationId: Info operationId: Info
@@ -1595,7 +1714,7 @@ paths:
type: array type: array
examples: examples:
'Example 1': 'Example 1':
value: [{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: []}}] value: [{uid: permissionId1String, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: user1-id, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {uid: permissionId2String, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {uid: group1-id, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
description: "Get the list of permission rules applicable the authenticated user.\nIf the user is an admin, all rules are returned." description: "Get the list of permission rules applicable the authenticated user.\nIf 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.' summary: 'Get the list of permission rules. If the user is admin, all rules are returned.'
tags: tags:
@@ -1615,7 +1734,7 @@ paths:
$ref: '#/components/schemas/PermissionDetailsResponse' $ref: '#/components/schemas/PermissionDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}} value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Create a new permission. Admin only.' summary: 'Create a new permission. Admin only.'
tags: tags:
- Permission - Permission
@@ -1629,7 +1748,7 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RegisterPermissionPayload' $ref: '#/components/schemas/RegisterPermissionPayload'
'/SASjsApi/permission/{permissionId}': '/SASjsApi/permission/{uid}':
patch: patch:
operationId: UpdatePermission operationId: UpdatePermission
responses: responses:
@@ -1641,7 +1760,7 @@ paths:
$ref: '#/components/schemas/PermissionDetailsResponse' $ref: '#/components/schemas/PermissionDetailsResponse'
examples: examples:
'Example 1': 'Example 1':
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}} value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Update permission setting. Admin only' summary: 'Update permission setting. Admin only'
tags: tags:
- Permission - Permission
@@ -1650,14 +1769,11 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'The permission''s identifier'
in: path in: path
name: permissionId name: uid
required: true required: true
schema: schema:
format: double type: string
type: number
example: 1234
requestBody: requestBody:
required: true required: true
content: content:
@@ -1677,14 +1793,11 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'The user''s identifier'
in: path in: path
name: permissionId name: uid
required: true required: true
schema: schema:
format: double type: string
type: number
example: 1234
/SASjsApi/session: /SASjsApi/session:
get: get:
operationId: Session operationId: Session
@@ -1694,10 +1807,10 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserResponse' $ref: '#/components/schemas/SessionResponse'
examples: examples:
'Example 1': 'Example 1':
value: {id: 123, username: johnusername, displayName: John, isAdmin: false} value: {id: userIdString, username: johnusername, displayName: John, isAdmin: false, needsToUpdatePassword: false}
summary: 'Get session info (username).' summary: 'Get session info (username).'
tags: tags:
- Session - Session
@@ -1717,7 +1830,7 @@ paths:
anyOf: anyOf:
- {type: string} - {type: string}
- {type: string, format: byte} - {type: string, format: byte}
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
summary: 'Execute a Stored Program, returns _webout and (optionally) log.' summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
tags: tags:
- STP - STP
@@ -1726,13 +1839,22 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'Location of code in SASjs Drive' description: 'Location of Stored Program in SASjs Drive.'
in: query in: query
name: _program name: _program
required: true required: true
schema: schema:
type: string type: string
example: /Projects/myApp/some/program example: /Projects/myApp/some/program
-
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
in: query
name: _debug
required: false
schema:
format: double
type: number
example: 131
post: post:
operationId: ExecutePostRequest operationId: ExecutePostRequest
responses: responses:
@@ -1791,7 +1913,7 @@ paths:
application/json: application/json:
schema: schema:
properties: properties:
user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object} user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
loggedIn: {type: boolean} loggedIn: {type: boolean}
required: required:
- user - user

View File

@@ -15,7 +15,7 @@ export const configureCors = (app: Express) => {
whiteList.push(url.replace(/\/$/, '')) whiteList.push(url.replace(/\/$/, ''))
}) })
console.log('All CORS Requests are enabled for:', whiteList) process.logger.info('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList })) app.use(cors({ credentials: true, origin: whiteList }))
} }
} }

View File

@@ -3,19 +3,27 @@ import mongoose from 'mongoose'
import session from 'express-session' import session from 'express-session'
import MongoStore from 'connect-mongo' import MongoStore from 'connect-mongo'
import { ModeType, ProtocolType } from '../utils' import { DatabaseType, ModeType, ProtocolType } from '../utils'
export const configureExpressSession = (app: Express) => { export const configureExpressSession = (app: Express) => {
const { MODE } = process.env const { MODE, DB_TYPE } = process.env
if (MODE === ModeType.Server) { if (MODE === ModeType.Server) {
let store: MongoStore | undefined let store: MongoStore | undefined
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
store = MongoStore.create({ if (DB_TYPE === DatabaseType.COSMOS_MONGODB) {
client: mongoose.connection!.getClient() as any, // COSMOS DB requires specific connection options (compatibility mode)
collectionName: 'sessions' // See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode
}) store = MongoStore.create({
client: mongoose.connection!.getClient() as any,
autoRemove: 'interval'
})
} else {
store = MongoStore.create({
client: mongoose.connection!.getClient() as any
})
}
} }
const { PROTOCOL, ALLOWED_DOMAIN } = process.env const { PROTOCOL, ALLOWED_DOMAIN } = process.env

View File

@@ -23,7 +23,7 @@ export const configureLogger = (app: Express) => {
path: logsFolder path: logsFolder
}) })
console.log('Writing Logs to :', path.join(logsFolder, filename)) process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
options = { stream: accessLogStream } options = { stream: accessLogStream }
} }

View File

@@ -5,12 +5,16 @@ import dotenv from 'dotenv'
import { import {
copySASjsCore, copySASjsCore,
createWeboutSasFile,
getFilesFolder,
getPackagesFolder,
getWebBuildFolder, getWebBuildFolder,
instantiateLogger, instantiateLogger,
loadAppStreamConfig, loadAppStreamConfig,
ReturnCode, ReturnCode,
setProcessVariables, setProcessVariables,
setupFolders, setupFilesFolder,
setupPackagesFolder,
setupUserAutoExec, setupUserAutoExec,
verifyEnvVariables verifyEnvVariables
} from './utils' } from './utils'
@@ -20,6 +24,7 @@ import {
configureLogger, configureLogger,
configureSecurity configureSecurity
} from './app-modules' } from './app-modules'
import { folderExists } from '@sasjs/utils'
dotenv.config() dotenv.config()
@@ -30,7 +35,7 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express() const app = express()
const onError: ErrorRequestHandler = (err, req, res, next) => { const onError: ErrorRequestHandler = (err, req, res, next) => {
console.error(err.stack) process.logger.error(err.stack)
res.status(500).send('Something broke!') res.status(500).send('Something broke!')
} }
@@ -65,9 +70,18 @@ export default setProcessVariables().then(async () => {
await setupUserAutoExec() await setupUserAutoExec()
if (process.driveLoc === path.join(process.sasjsRoot, 'drive')) { if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
await setupFolders()
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 copySASjsCore()
await createWeboutSasFile()
} }
// loading these modules after setting up variables due to // loading these modules after setting up variables due to

View File

@@ -1,4 +1,16 @@
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 jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
import { import {
@@ -9,19 +21,20 @@ import {
saveTokensInDB saveTokensInDB
} from '../utils' } from '../utils'
import Client from '../model/Client' import Client from '../model/Client'
import User from '../model/User'
@Route('SASjsApi/auth') @Route('SASjsApi/auth')
@Tags('Auth') @Tags('Auth')
export class AuthController { export class AuthController {
static authCodes: { [key: string]: { [key: string]: string } } = {} static authCodes: { [key: string]: { [key: string]: string } } = {}
static saveCode = (userId: number, clientId: string, code: string) => { static saveCode = (userId: string, clientId: string, code: string) => {
if (AuthController.authCodes[userId]) if (AuthController.authCodes[userId])
return (AuthController.authCodes[userId][clientId] = code) return (AuthController.authCodes[userId][clientId] = code)
AuthController.authCodes[userId] = { [clientId]: code } AuthController.authCodes[userId] = { [clientId]: code }
return AuthController.authCodes[userId][clientId] return AuthController.authCodes[userId][clientId]
} }
static deleteCode = (userId: number, clientId: string) => static deleteCode = (userId: string, clientId: string) =>
delete AuthController.authCodes[userId][clientId] delete AuthController.authCodes[userId][clientId]
/** /**
@@ -62,6 +75,18 @@ export class AuthController {
public async logout(@Query() @Hidden() data?: InfoJWT) { public async logout(@Query() @Hidden() data?: InfoJWT) {
return logout(data!) 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> => { const token = async (data: any): Promise<TokenResponse> => {
@@ -128,6 +153,40 @@ const logout = async (userInfo: InfoJWT) => {
await removeTokensInDB(userInfo.userId, userInfo.clientId) 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 { interface TokenPayload {
/** /**
* Client ID * Client ID
@@ -154,6 +213,19 @@ interface TokenResponse {
refreshToken: string refreshToken: string
} }
interface UpdatePasswordPayload {
/**
* Current Password
* @example "currentPasswordString"
*/
currentPassword: string
/**
* New Password
* @example "newPassword"
*/
newPassword: string
}
const verifyAuthCode = async ( const verifyAuthCode = async (
clientId: string, clientId: string,
code: string code: string

View File

@@ -74,7 +74,8 @@ const synchroniseWithLDAP = async () => {
displayName: user.displayName, displayName: user.displayName,
username: user.username, username: user.username,
password: hashPassword, password: hashPassword,
authProvider: AuthProviderType.LDAP authProvider: AuthProviderType.LDAP,
needsToUpdatePassword: false
}) })
importedUsers.push(user) importedUsers.push(user)

View File

@@ -1,4 +1,4 @@
import { Security, Route, Tags, Example, Post, Body } from 'tsoa' import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
import Client, { import Client, {
ClientPayload, ClientPayload,
@@ -13,8 +13,8 @@ export class ClientController {
* @summary Admin only task. Create client with the following attributes: * @summary Admin only task. Create client with the following attributes:
* ClientId, * ClientId,
* ClientSecret, * ClientSecret,
* accessTokenExpiryDays (optional), * accessTokenExpiration (optional),
* refreshTokenExpiryDays (optional) * refreshTokenExpiration (optional)
* *
*/ */
@Example<ClientPayload>({ @Example<ClientPayload>({
@@ -29,6 +29,28 @@ export class ClientController {
): Promise<ClientPayload> { ): Promise<ClientPayload> {
return createClient(body) return createClient(body)
} }
/**
* @summary Admin only task. Returns the list of all the clients
*/
@Example<ClientPayload[]>([
{
clientId: 'someClientID1234',
clientSecret: 'someRandomCryptoString',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
},
{
clientId: 'someOtherClientID',
clientSecret: 'someOtherRandomCryptoString',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
}
])
@Get('/')
public async getAllClients(): Promise<ClientPayload[]> {
return getAllClients()
}
} }
const createClient = async (data: ClientPayload): Promise<ClientPayload> => { const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
@@ -60,3 +82,13 @@ const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
refreshTokenExpiration: savedClient.refreshTokenExpiration refreshTokenExpiration: savedClient.refreshTokenExpiration
} }
} }
const getAllClients = async (): Promise<ClientPayload[]> => {
return Client.find({}).select({
_id: 0,
clientId: 1,
clientSecret: 1,
accessTokenExpiration: 1,
refreshTokenExpiration: 1
})
}

View File

@@ -1,34 +1,69 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Post, Body } from 'tsoa' import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
import { ExecutionController } from './internal' import { ExecutionController, getSessionController } from './internal'
import { import {
getPreProgramVariables, getPreProgramVariables,
getUserAutoExec, getUserAutoExec,
ModeType, ModeType,
parseLogToArray,
RunTimeType RunTimeType
} from '../utils' } from '../utils'
interface ExecuteCodePayload { interface ExecuteCodePayload {
/** /**
* Code of program * The code to be executed
* @example "* Code HERE;" * @example "* Your Code HERE;"
*/ */
code: string code: string
/** /**
* runtime for program * The runtime for the code - eg SAS, JS, PY or R
* @example "js" * @example "js"
*/ */
runTime: RunTimeType runTime: RunTimeType
} }
interface TriggerCodePayload {
/**
* The code to be executed
* @example "* Your Code HERE;"
*/
code: string
/**
* The runtime for the code - eg SAS, JS, PY or R
* @example "sas"
*/
runTime: RunTimeType
/**
* Amount of minutes after the completion of the job when the session must be
* destroyed.
* @example 15
*/
expiresAfterMins?: number
}
interface TriggerCodeResponse {
/**
* The SessionId is the name of the temporary folder used to store the outputs.
* For SAS, this would be the SASWORK folder. Can be used to poll job status.
* This session ID should be used to poll job status.
* @example "{ sessionId: '20241028074744-54132-1730101664824' }"
*/
sessionId: string
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/code') @Route('SASjsApi/code')
@Tags('Code') @Tags('Code')
export class CodeController { export class CodeController {
/** /**
* Execute Code on the Specified Runtime * Execute Code on the Specified Runtime
* @summary Run Code and Return Webout Content and Log * @summary Run Code and Return Webout Content, Log and Print output
* The order of returned parts of the payload is:
* 1. Webout (if present)
* 2. Logs UUID (used as separator)
* 3. Log
* 4. Logs UUID (used as separator)
* 5. Print (if present and if the runtime is SAS)
* Please see @sasjs/server/api/src/controllers/internal/Execution.ts for more information
*/ */
@Post('/execute') @Post('/execute')
public async executeCode( public async executeCode(
@@ -37,6 +72,18 @@ export class CodeController {
): Promise<string | Buffer> { ): Promise<string | Buffer> {
return executeCode(request, body) return executeCode(request, body)
} }
/**
* Trigger Code on the Specified Runtime
* @summary Triggers code and returns SessionId immediately - does not wait for job completion
*/
@Post('/trigger')
public async triggerCode(
@Request() request: express.Request,
@Body() body: TriggerCodePayload
): Promise<TriggerCodeResponse> {
return triggerCode(request, body)
}
} }
const executeCode = async ( const executeCode = async (
@@ -55,7 +102,8 @@ const executeCode = async (
preProgramVariables: getPreProgramVariables(req), preProgramVariables: getPreProgramVariables(req),
vars: { ...req.query, _debug: 131 }, vars: { ...req.query, _debug: 131 },
otherArgs: { userAutoExec }, otherArgs: { userAutoExec },
runTime: runTime runTime: runTime,
includePrintOutput: true
}) })
return result return result
@@ -68,3 +116,49 @@ const executeCode = async (
} }
} }
} }
const triggerCode = async (
req: express.Request,
{ code, runTime, expiresAfterMins }: TriggerCodePayload
): Promise<{ sessionId: string }> => {
const { user } = req
const userAutoExec =
process.env.MODE === ModeType.Server
? user?.autoExec
: await getUserAutoExec()
// get session controller based on runTime
const sessionController = getSessionController(runTime)
// get session
const session = await sessionController.getSession()
// add expiresAfterMins to session if provided
if (expiresAfterMins) {
// expiresAfterMins.used is set initially to false
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
}
try {
// call executeProgram method of ExecutionController without awaiting
new ExecutionController().executeProgram({
program: code,
preProgramVariables: getPreProgramVariables(req),
vars: { ...req.query, _debug: 131 },
otherArgs: { userAutoExec },
runTime: runTime,
includePrintOutput: true,
session // session is provided
})
// return session id
return { sessionId: session.id }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}

View File

@@ -12,28 +12,29 @@ import {
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group' import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User' import User from '../model/User'
import { AuthProviderType } from '../utils' import { GetUserBy, UserResponse } from './user'
import { UserResponse } from './user'
export interface GroupResponse { export interface GroupResponse {
groupId: number uid: string
name: string name: string
description: string description: string
} }
export interface GroupDetailsResponse { export interface GroupDetailsResponse extends GroupResponse {
groupId: number
name: string
description: string
isActive: boolean isActive: boolean
users: UserResponse[] users: UserResponse[]
} }
interface GetGroupBy { interface GetGroupBy {
groupId?: number _id?: string
name?: string name?: string
} }
enum GroupAction {
AddUser = 'addUser',
RemoveUser = 'removeUser'
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/group') @Route('SASjsApi/group')
@Tags('Group') @Tags('Group')
@@ -44,7 +45,7 @@ export class GroupController {
*/ */
@Example<GroupResponse[]>([ @Example<GroupResponse[]>([
{ {
groupId: 123, uid: 'groupIdString',
name: 'DCGroup', name: 'DCGroup',
description: 'This group represents Data Controller Users' description: 'This group represents Data Controller Users'
} }
@@ -59,7 +60,7 @@ export class GroupController {
* *
*/ */
@Example<GroupDetailsResponse>({ @Example<GroupDetailsResponse>({
groupId: 123, uid: 'groupIdString',
name: 'DCGroup', name: 'DCGroup',
description: 'This group represents Data Controller Users', description: 'This group represents Data Controller Users',
isActive: true, isActive: true,
@@ -78,7 +79,7 @@ export class GroupController {
* @example dcgroup * @example dcgroup
*/ */
@Get('by/groupname/{name}') @Get('by/groupname/{name}')
public async getGroupByGroupName( public async getGroupByName(
@Path() name: string @Path() name: string
): Promise<GroupDetailsResponse> { ): Promise<GroupDetailsResponse> {
return getGroup({ name }) return getGroup({ name })
@@ -86,68 +87,66 @@ export class GroupController {
/** /**
* @summary Get list of members of a group (userName). All users can request this. * @summary Get list of members of a group (userName). All users can request this.
* @param groupId The group's identifier * @param uid The group's identifier
* @example groupId 1234 * @example uid "12ByteString"
*/ */
@Get('{groupId}') @Get('{uid}')
public async getGroup( public async getGroup(@Path() uid: string): Promise<GroupDetailsResponse> {
@Path() groupId: number return getGroup({ _id: uid })
): Promise<GroupDetailsResponse> {
return getGroup({ groupId })
} }
/** /**
* @summary Add a user to a group. Admin task only. * @summary Add a user to a group. Admin task only.
* @param groupId The group's identifier * @param groupUid The group's identifier
* @example groupId "1234" * @example groupUid "12ByteString"
* @param userId The user's identifier * @param userUid The user's identifier
* @example userId "6789" * @example userId "12ByteString"
*/ */
@Example<GroupDetailsResponse>({ @Example<GroupDetailsResponse>({
groupId: 123, uid: 'groupIdString',
name: 'DCGroup', name: 'DCGroup',
description: 'This group represents Data Controller Users', description: 'This group represents Data Controller Users',
isActive: true, isActive: true,
users: [] users: []
}) })
@Post('{groupId}/{userId}') @Post('{groupUid}/{userUid}')
public async addUserToGroup( public async addUserToGroup(
@Path() groupId: number, @Path() groupUid: string,
@Path() userId: number @Path() userUid: string
): Promise<GroupDetailsResponse> { ): Promise<GroupDetailsResponse> {
return addUserToGroup(groupId, userId) return addUserToGroup(groupUid, userUid)
} }
/** /**
* @summary Remove a user to a group. Admin task only. * @summary Remove a user from a group. Admin task only.
* @param groupId The group's identifier * @param groupUid The group's identifier
* @example groupId "1234" * @example groupUid "12ByteString"
* @param userId The user's identifier * @param userUid The user's identifier
* @example userId "6789" * @example userUid "12ByteString"
*/ */
@Example<GroupDetailsResponse>({ @Example<GroupDetailsResponse>({
groupId: 123, uid: 'groupIdString',
name: 'DCGroup', name: 'DCGroup',
description: 'This group represents Data Controller Users', description: 'This group represents Data Controller Users',
isActive: true, isActive: true,
users: [] users: []
}) })
@Delete('{groupId}/{userId}') @Delete('{groupUid}/{userUid}')
public async removeUserFromGroup( public async removeUserFromGroup(
@Path() groupId: number, @Path() groupUid: string,
@Path() userId: number @Path() userUid: string
): Promise<GroupDetailsResponse> { ): Promise<GroupDetailsResponse> {
return removeUserFromGroup(groupId, userId) return removeUserFromGroup(groupUid, userUid)
} }
/** /**
* @summary Delete a group. Admin task only. * @summary Delete a group. Admin task only.
* @param groupId The group's identifier * @param uid The group's identifier
* @example groupId 1234 * @example uid "12ByteString"
*/ */
@Delete('{groupId}') @Delete('{uid}')
public async deleteGroup(@Path() groupId: number) { public async deleteGroup(@Path() uid: string) {
const group = await Group.findOne({ groupId }) const group = await Group.findOne({ _id: uid })
if (!group) if (!group)
throw { throw {
code: 404, code: 404,
@@ -160,9 +159,7 @@ export class GroupController {
} }
const getAllGroups = async (): Promise<GroupResponse[]> => const getAllGroups = async (): Promise<GroupResponse[]> =>
await Group.find({}) await Group.find({}).select('uid name description').exec()
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
.exec()
const createGroup = async ({ const createGroup = async ({
name, name,
@@ -187,7 +184,7 @@ const createGroup = async ({
const savedGroup = await group.save() const savedGroup = await group.save()
return { return {
groupId: savedGroup.groupId, uid: savedGroup.uid,
name: savedGroup.name, name: savedGroup.name,
description: savedGroup.description, description: savedGroup.description,
isActive: savedGroup.isActive, isActive: savedGroup.isActive,
@@ -198,11 +195,12 @@ const createGroup = async ({
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => { const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
const group = (await Group.findOne( const group = (await Group.findOne(
findBy, findBy,
'groupId name description isActive users -_id' 'uid name description isActive users'
).populate( ).populate(
'users', 'users',
'id username displayName isAdmin -_id' 'uid username displayName isAdmin'
)) as unknown as GroupDetailsResponse )) as unknown as GroupDetailsResponse
if (!group) if (!group)
throw { throw {
code: 404, code: 404,
@@ -211,7 +209,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
} }
return { return {
groupId: group.groupId, uid: group.uid,
name: group.name, name: group.name,
description: group.description, description: group.description,
isActive: group.isActive, isActive: group.isActive,
@@ -220,23 +218,23 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
} }
const addUserToGroup = async ( const addUserToGroup = async (
groupId: number, groupUid: string,
userId: number userUid: string
): Promise<GroupDetailsResponse> => ): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'addUser') updateUsersListInGroup(groupUid, userUid, GroupAction.AddUser)
const removeUserFromGroup = async ( const removeUserFromGroup = async (
groupId: number, groupUid: string,
userId: number userUid: string
): Promise<GroupDetailsResponse> => ): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'removeUser') updateUsersListInGroup(groupUid, userUid, GroupAction.RemoveUser)
const updateUsersListInGroup = async ( const updateUsersListInGroup = async (
groupId: number, groupUid: string,
userId: number, userUid: string,
action: 'addUser' | 'removeUser' action: GroupAction
): Promise<GroupDetailsResponse> => { ): Promise<GroupDetailsResponse> => {
const group = await Group.findOne({ groupId }) const group = await Group.findOne({ _id: groupUid })
if (!group) if (!group)
throw { throw {
code: 404, code: 404,
@@ -258,7 +256,7 @@ const updateUsersListInGroup = async (
message: `Can't add/remove user to group created by external auth provider.` message: `Can't add/remove user to group created by external auth provider.`
} }
const user = await User.findOne({ id: userId }) const user = await User.findOne({ _id: userUid })
if (!user) if (!user)
throw { throw {
code: 404, code: 404,
@@ -274,7 +272,7 @@ const updateUsersListInGroup = async (
} }
const updatedGroup = const updatedGroup =
action === 'addUser' action === GroupAction.AddUser
? await group.addUser(user) ? await group.addUser(user)
: await group.removeUser(user) : await group.removeUser(user)
@@ -286,7 +284,7 @@ const updateUsersListInGroup = async (
} }
return { return {
groupId: updatedGroup.groupId, uid: updatedGroup.uid,
name: updatedGroup.name, name: updatedGroup.name,
description: updatedGroup.description, description: updatedGroup.description,
isActive: updatedGroup.isActive, isActive: updatedGroup.isActive,

View File

@@ -33,6 +33,7 @@ interface ExecuteFileParams {
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> { interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
program: string program: string
includePrintOutput?: boolean
} }
export class ExecutionController { export class ExecutionController {
@@ -67,7 +68,8 @@ export class ExecutionController {
otherArgs, otherArgs,
session: sessionByFileUpload, session: sessionByFileUpload,
runTime, runTime,
forceStringResult forceStringResult,
includePrintOutput
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> { }: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
const sessionController = getSessionController(runTime) const sessionController = getSessionController(runTime)
@@ -105,6 +107,11 @@ export class ExecutionController {
? await readFile(headersPath) ? await readFile(headersPath)
: '' : ''
const httpHeaders: HTTPHeaders = extractHeaders(headersContent) const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
if (isDebugOn(vars)) {
httpHeaders['content-type'] = 'text/plain'
}
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type') const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
const webout = (await fileExists(weboutPath)) const webout = (await fileExists(weboutPath))
@@ -116,12 +123,29 @@ export class ExecutionController {
// it should be deleted by scheduleSessionDestroy // it should be deleted by scheduleSessionDestroy
session.inUse = false session.inUse = false
const resultParts = []
// INFO: webout can be a Buffer, that is why it's length should be checked to determine if it is empty
if (webout && webout.length !== 0) resultParts.push(webout)
// INFO: log separator wraps the log from the beginning and the end
resultParts.push(process.logsUUID)
resultParts.push(log)
resultParts.push(process.logsUUID)
if (includePrintOutput && runTime === RunTimeType.SAS) {
const printOutputPath = path.join(session.path, 'output.lst')
const printOutput = (await fileExists(printOutputPath))
? await readFile(printOutputPath)
: ''
if (printOutput) resultParts.push(printOutput)
}
return { return {
httpHeaders, httpHeaders,
result: result:
isDebugOn(vars) || session.crashed isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout
? `${webout}\n${process.logsUUID}\n${log}`
: webout
} }
} }

View File

@@ -14,8 +14,7 @@ import {
createFile, createFile,
fileExists, fileExists,
generateTimestamp, generateTimestamp,
readFile, readFile
isWindows
} from '@sasjs/utils' } from '@sasjs/utils'
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
@@ -50,7 +49,7 @@ export class SessionController {
} }
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'Content-type: text/plain') await createFile(headersPath, 'content-type: text/html; charset=utf-8')
this.sessions.push(session) this.sessions.push(session)
return session return session
@@ -94,7 +93,7 @@ export class SASSessionController extends SessionController {
} }
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'Content-type: text/plain') await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
// we do not want to leave sessions running forever // we do not want to leave sessions running forever
// we clean them up after a predefined period, if unused // we clean them up after a predefined period, if unused
@@ -134,23 +133,24 @@ ${autoExecContent}`
session.path, session.path,
'-AUTOEXEC', '-AUTOEXEC',
autoExecPath, autoExecPath,
isWindows() ? '-nologo' : '', process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '', process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '', process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '', process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '', process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '', process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '', process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
process.sasLoc!.endsWith('sas.exe') ? session.path : '' process.sasLoc!.endsWith('sas.exe') ? session.path : ''
]) ])
.then(() => { .then(() => {
session.completed = true session.completed = true
console.log('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.completed = true
session.crashed = err.toString() 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 // we have a triggered session - add to array
@@ -170,7 +170,10 @@ ${autoExecContent}`
while ((await fileExists(codeFilePath)) && !session.crashed) {} while ((await fileExists(codeFilePath)) && !session.crashed) {}
if (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 session.ready = true
} }
@@ -186,29 +189,52 @@ ${autoExecContent}`
} }
private scheduleSessionDestroy(session: Session) { private scheduleSessionDestroy(session: Session) {
setTimeout(async () => { setTimeout(
if (session.inUse) { async () => {
// adding 10 more minutes if (session.inUse) {
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000 // adding 10 more minutes
session.deathTimeStamp = newDeathTimeStamp.toString() const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
session.deathTimeStamp = newDeathTimeStamp.toString()
this.scheduleSessionDestroy(session) this.scheduleSessionDestroy(session)
} else { } else {
await this.deleteSession(session) const { expiresAfterMins } = session
}
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100) // delay session destroy if expiresAfterMins present
if (expiresAfterMins && !expiresAfterMins.used) {
// calculate session death time using expiresAfterMins
const newDeathTimeStamp =
parseInt(session.deathTimeStamp) +
expiresAfterMins.mins * 60 * 1000
session.deathTimeStamp = newDeathTimeStamp.toString()
// set expiresAfterMins to true to avoid using it again
session.expiresAfterMins!.used = true
this.scheduleSessionDestroy(session)
} else {
await this.deleteSession(session)
}
}
},
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
)
} }
} }
export const getSessionController = ( export const getSessionController = (
runTime: RunTimeType runTime: RunTimeType
): SessionController => { ): SessionController => {
if (process.sessionController) return process.sessionController if (runTime === RunTimeType.SAS) {
process.sasSessionController =
process.sasSessionController || new SASSessionController()
return process.sasSessionController
}
process.sessionController = process.sessionController =
runTime === RunTimeType.SAS process.sessionController || new SessionController()
? new SASSessionController()
: new SessionController()
return process.sessionController return process.sessionController
} }

View File

@@ -15,7 +15,7 @@ export const createJSProgram = async (
) => { ) => {
const varStatments = Object.keys(vars).reduce( const varStatments = Object.keys(vars).reduce(
(computed: string, key: string) => (computed: string, key: string) =>
`${computed}const ${key} = '${vars[key]}';\n`, `${computed}const ${key} = \`${vars[key]}\`;\n`,
'' ''
) )

View File

@@ -40,8 +40,6 @@ export const createSASProgram = async (
%mend; %mend;
%_sasjs_server_init() %_sasjs_server_init()
proc printto print="%sysfunc(getoption(log))";
run;
` `
program = ` program = `

View File

@@ -1,6 +1,6 @@
import path from 'path' import path from 'path'
import fs from 'fs' import { WriteStream, createWriteStream } from 'fs'
import { execFileSync } from 'child_process' import { execFile } from 'child_process'
import { once } from 'stream' import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils' import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types' import { PreProgramVars, Session } from '../../types'
@@ -105,26 +105,58 @@ export const processProgram = async (
throw new Error('Invalid runtime!') throw new Error('Invalid runtime!')
} }
try { await createFile(codePath, program)
await createFile(codePath, program)
// create a stream that will write to console outputs to log file // create a stream that will write to console outputs to log file
const writeStream = fs.createWriteStream(logPath) const writeStream = createWriteStream(logPath)
// waiting for the open event so that we can have underlying file descriptor // waiting for the open event so that we can have underlying file descriptor
await once(writeStream, 'open') await once(writeStream, 'open')
execFileSync(executablePath, [codePath], {
stdio: ['ignore', writeStream, writeStream] await execFilePromise(executablePath, [codePath], writeStream)
.then(() => {
session.completed = true
process.logger.info('session completed', session)
}) })
// copy the code file to log and end write stream .catch((err) => {
writeStream.end(program) session.completed = true
session.completed = true session.crashed = err.toString()
console.log('session completed', session) process.logger.error('session crashed', session.id, session.crashed)
} catch (err: any) { })
session.completed = true
session.crashed = err.toString() // copy the code file to log and end write stream
console.log('session crashed', session.id, session.crashed) 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)) const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -107,7 +107,7 @@ export class MockSas9Controller {
content: result.result as string content: result.result as string
} }
} catch (err) { } catch (err) {
console.log('err', err) process.logger.error('err', err)
} }
return { return {
@@ -168,7 +168,7 @@ export class MockSas9Controller {
content: result.result as string content: result.result as string
} }
} catch (err) { } catch (err) {
console.log('err', err) process.logger.error('err', err)
} }
return { return {
@@ -269,7 +269,7 @@ const getMockResponseFromFile = async (
let file = await readFile(filePathParsed).catch((err: any) => { let file = await readFile(filePathParsed).catch((err: any) => {
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}` const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
console.error(errMsg) process.logger.error(errMsg)
error = true error = true

View File

@@ -56,9 +56,9 @@ interface RegisterPermissionPayload {
principalType: PrincipalType principalType: PrincipalType
/** /**
* The id of user or group to which a rule is assigned. * The id of user or group to which a rule is assigned.
* @example 123 * @example 'groupIdString'
*/ */
principalId: number principalId: string
} }
interface UpdatePermissionPayload { interface UpdatePermissionPayload {
@@ -70,7 +70,7 @@ interface UpdatePermissionPayload {
} }
export interface PermissionDetailsResponse { export interface PermissionDetailsResponse {
permissionId: number uid: string
path: string path: string
type: string type: string
setting: string setting: string
@@ -91,24 +91,24 @@ export class PermissionController {
*/ */
@Example<PermissionDetailsResponse[]>([ @Example<PermissionDetailsResponse[]>([
{ {
permissionId: 123, uid: 'permissionId1String',
path: '/SASjsApi/code/execute', path: '/SASjsApi/code/execute',
type: 'Route', type: 'Route',
setting: 'Grant', setting: 'Grant',
user: { user: {
id: 1, uid: 'user1-id',
username: 'johnSnow01', username: 'johnSnow01',
displayName: 'John Snow', displayName: 'John Snow',
isAdmin: false isAdmin: false
} }
}, },
{ {
permissionId: 124, uid: 'permissionId2String',
path: '/SASjsApi/code/execute', path: '/SASjsApi/code/execute',
type: 'Route', type: 'Route',
setting: 'Grant', setting: 'Grant',
group: { group: {
groupId: 1, uid: 'group1-id',
name: 'DCGroup', name: 'DCGroup',
description: 'This group represents Data Controller Users', description: 'This group represents Data Controller Users',
isActive: true, isActive: true,
@@ -128,12 +128,12 @@ export class PermissionController {
* *
*/ */
@Example<PermissionDetailsResponse>({ @Example<PermissionDetailsResponse>({
permissionId: 123, uid: 'permissionIdString',
path: '/SASjsApi/code/execute', path: '/SASjsApi/code/execute',
type: 'Route', type: 'Route',
setting: 'Grant', setting: 'Grant',
user: { user: {
id: 1, uid: 'userIdString',
username: 'johnSnow01', username: 'johnSnow01',
displayName: 'John Snow', displayName: 'John Snow',
isAdmin: false isAdmin: false
@@ -149,36 +149,36 @@ export class PermissionController {
/** /**
* @summary Update permission setting. Admin only * @summary Update permission setting. Admin only
* @param permissionId The permission's identifier * @param permissionId The permission's identifier
* @example permissionId 1234 * @example permissionId "permissionIdString"
*/ */
@Example<PermissionDetailsResponse>({ @Example<PermissionDetailsResponse>({
permissionId: 123, uid: 'permissionIdString',
path: '/SASjsApi/code/execute', path: '/SASjsApi/code/execute',
type: 'Route', type: 'Route',
setting: 'Grant', setting: 'Grant',
user: { user: {
id: 1, uid: 'userIdString',
username: 'johnSnow01', username: 'johnSnow01',
displayName: 'John Snow', displayName: 'John Snow',
isAdmin: false isAdmin: false
} }
}) })
@Patch('{permissionId}') @Patch('{uid}')
public async updatePermission( public async updatePermission(
@Path() permissionId: number, @Path() uid: string,
@Body() body: UpdatePermissionPayload @Body() body: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> { ): Promise<PermissionDetailsResponse> {
return updatePermission(permissionId, body) return updatePermission(uid, body)
} }
/** /**
* @summary Delete a permission. Admin only. * @summary Delete a permission. Admin only.
* @param permissionId The user's identifier * @param permissionId The user's identifier
* @example permissionId 1234 * @example permissionId "permissionIdString"
*/ */
@Delete('{permissionId}') @Delete('{uid}')
public async deletePermission(@Path() permissionId: number) { public async deletePermission(@Path() uid: string) {
return deletePermission(permissionId) return deletePermission(uid)
} }
} }
@@ -191,7 +191,7 @@ const getAllPermissions = async (
else { else {
const permissions: PermissionDetailsResponse[] = [] const permissions: PermissionDetailsResponse[] = []
const dbUser = await User.findOne({ id: user?.userId }) const dbUser = await User.findOne({ _id: user?.userId })
if (!dbUser) if (!dbUser)
throw { throw {
code: 404, code: 404,
@@ -227,7 +227,7 @@ const createPermission = async ({
switch (principalType) { switch (principalType) {
case PrincipalType.user: { case PrincipalType.user: {
const userInDB = await User.findOne({ id: principalId }) const userInDB = await User.findOne({ _id: principalId })
if (!userInDB) if (!userInDB)
throw { throw {
code: 404, code: 404,
@@ -259,7 +259,7 @@ const createPermission = async ({
permission.user = userInDB._id permission.user = userInDB._id
user = { user = {
id: userInDB.id, uid: userInDB.uid,
username: userInDB.username, username: userInDB.username,
displayName: userInDB.displayName, displayName: userInDB.displayName,
isAdmin: userInDB.isAdmin isAdmin: userInDB.isAdmin
@@ -267,7 +267,7 @@ const createPermission = async ({
break break
} }
case PrincipalType.group: { case PrincipalType.group: {
const groupInDB = await Group.findOne({ groupId: principalId }) const groupInDB = await Group.findOne({ _id: principalId })
if (!groupInDB) if (!groupInDB)
throw { throw {
code: 404, code: 404,
@@ -291,13 +291,13 @@ const createPermission = async ({
permission.group = groupInDB._id permission.group = groupInDB._id
group = { group = {
groupId: groupInDB.groupId, uid: groupInDB.uid,
name: groupInDB.name, name: groupInDB.name,
description: groupInDB.description, description: groupInDB.description,
isActive: groupInDB.isActive, isActive: groupInDB.isActive,
users: groupInDB.populate({ users: groupInDB.populate({
path: 'users', path: 'users',
select: 'id username displayName isAdmin -_id', select: 'uid username displayName isAdmin -_id',
options: { limit: 15 } options: { limit: 15 }
}) as unknown as UserResponse[] }) as unknown as UserResponse[]
} }
@@ -314,7 +314,7 @@ const createPermission = async ({
const savedPermission = await permission.save() const savedPermission = await permission.save()
return { return {
permissionId: savedPermission.permissionId, uid: savedPermission.uid,
path: savedPermission.path, path: savedPermission.path,
type: savedPermission.type, type: savedPermission.type,
setting: savedPermission.setting, setting: savedPermission.setting,
@@ -324,27 +324,21 @@ const createPermission = async ({
} }
const updatePermission = async ( const updatePermission = async (
id: number, uid: string,
data: UpdatePermissionPayload data: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> => { ): Promise<PermissionDetailsResponse> => {
const { setting } = data const { setting } = data
const updatedPermission = (await Permission.findOneAndUpdate( const updatedPermission = (await Permission.findOneAndUpdate(
{ permissionId: id }, { _id: uid },
{ setting }, { setting },
{ new: true } { new: true }
) )
.select({ .select('uid path type setting')
_id: 0, .populate({ path: 'user', select: 'uid username displayName isAdmin' })
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({ .populate({
path: 'group', path: 'group',
select: 'groupId name description -_id' select: 'groupId name description'
})) as unknown as PermissionDetailsResponse })) as unknown as PermissionDetailsResponse
if (!updatedPermission) if (!updatedPermission)
throw { throw {
@@ -356,13 +350,13 @@ const updatePermission = async (
return updatedPermission return updatedPermission
} }
const deletePermission = async (id: number) => { const deletePermission = async (uid: string) => {
const permission = await Permission.findOne({ permissionId: id }) const permission = await Permission.findOne({ _id: uid })
if (!permission) if (!permission)
throw { throw {
code: 404, code: 404,
status: 'Not Found', status: 'Not Found',
message: 'Permission not found.' message: 'Permission not found.'
} }
await Permission.deleteOne({ permissionId: id }) await Permission.deleteOne({ _id: uid })
} }

View File

@@ -2,6 +2,11 @@ import express from 'express'
import { Request, Security, Route, Tags, Example, Get } from 'tsoa' import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
import { UserResponse } from './user' import { UserResponse } from './user'
interface SessionResponse extends Omit<UserResponse, 'uid'> {
id: string
needsToUpdatePassword?: boolean
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/session') @Route('SASjsApi/session')
@Tags('Session') @Tags('Session')
@@ -10,16 +15,17 @@ export class SessionController {
* @summary Get session info (username). * @summary Get session info (username).
* *
*/ */
@Example<UserResponse>({ @Example<SessionResponse>({
id: 123, id: 'userIdString',
username: 'johnusername', username: 'johnusername',
displayName: 'John', displayName: 'John',
isAdmin: false isAdmin: false,
needsToUpdatePassword: false
}) })
@Get('/') @Get('/')
public async session( public async session(
@Request() request: express.Request @Request() request: express.Request
): Promise<UserResponse> { ): Promise<SessionResponse> {
return session(request) return session(request)
} }
} }
@@ -28,5 +34,6 @@ const session = (req: express.Request) => ({
id: req.user!.userId, id: req.user!.userId,
username: req.user!.username, username: req.user!.username,
displayName: req.user!.displayName, displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin isAdmin: req.user!.isAdmin,
needsToUpdatePassword: req.user!.needsToUpdatePassword
}) })

View File

@@ -3,12 +3,11 @@ import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
import { ExecutionController, ExecutionVars } from './internal' import { ExecutionController, ExecutionVars } from './internal'
import { import {
getPreProgramVariables, getPreProgramVariables,
HTTPHeaders,
LogLine,
makeFilesNamesMap, makeFilesNamesMap,
getRunTimeAndFilePath getRunTimeAndFilePath
} from '../utils' } from '../utils'
import { MulterFile } from '../types/Upload' import { MulterFile } from '../types/Upload'
import { debug } from 'console'
interface ExecutePostRequestPayload { interface ExecutePostRequestPayload {
/** /**
@@ -25,20 +24,31 @@ export class STPController {
/** /**
* Trigger a Stored 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: * Accepts additional URL parameters (converted to session variables)
* and file uploads. For more details, see docs:
* *
* https://server.sasjs.io/storedprograms * https://server.sasjs.io/storedprograms
* *
* @summary Execute a Stored Program, returns _webout and (optionally) log. * @summary Execute a Stored Program, returns _webout and (optionally) log.
* @param _program Location of code in SASjs Drive * @param _program Location of Stored Program in SASjs Drive.
* @param _debug Optional query param for setting debug mode (returns the session log in the response body).
* @example _program "/Projects/myApp/some/program" * @example _program "/Projects/myApp/some/program"
* @example _debug 131
*/ */
@Get('/execute') @Get('/execute')
public async executeGetRequest( public async executeGetRequest(
@Request() request: express.Request, @Request() request: express.Request,
@Query() _program: string @Query() _program: string,
@Query() _debug?: number
): Promise<string | Buffer> { ): Promise<string | Buffer> {
const vars = request.query as ExecutionVars let vars = request.query as ExecutionVars
if (_debug) {
vars = {
...vars,
_debug
}
}
return execute(request, _program, vars) return execute(request, _program, vars)
} }
@@ -91,6 +101,8 @@ const execute = async (
} }
) )
req.res?.header(httpHeaders)
if (result instanceof Buffer) { if (result instanceof Buffer) {
;(req as any).sasHeaders = httpHeaders ;(req as any).sasHeaders = httpHeaders
} }

View File

@@ -21,23 +21,19 @@ import {
getUserAutoExec, getUserAutoExec,
updateUserAutoExec, updateUserAutoExec,
ModeType, ModeType,
AuthProviderType ALL_USERS_GROUP
} from '../utils' } from '../utils'
import { GroupResponse } from './group' import { GroupController, GroupResponse } from './group'
export interface UserResponse { export interface UserResponse {
id: number uid: string
username: string username: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
} }
export interface UserDetailsResponse { export interface UserDetailsResponse extends UserResponse {
id: number
displayName: string
username: string
isActive: boolean isActive: boolean
isAdmin: boolean
autoExec?: string autoExec?: string
groups?: GroupResponse[] groups?: GroupResponse[]
} }
@@ -52,13 +48,13 @@ export class UserController {
*/ */
@Example<UserResponse[]>([ @Example<UserResponse[]>([
{ {
id: 123, uid: 'userIdString',
username: 'johnusername', username: 'johnusername',
displayName: 'John', displayName: 'John',
isAdmin: false isAdmin: false
}, },
{ {
id: 456, uid: 'anotherUserIdString',
username: 'starkusername', username: 'starkusername',
displayName: 'Stark', displayName: 'Stark',
isAdmin: true isAdmin: true
@@ -74,7 +70,7 @@ export class UserController {
* *
*/ */
@Example<UserDetailsResponse>({ @Example<UserDetailsResponse>({
id: 1234, uid: 'userIdString',
displayName: 'John Snow', displayName: 'John Snow',
username: 'johnSnow01', username: 'johnSnow01',
isAdmin: false, isAdmin: false,
@@ -111,20 +107,20 @@ export class UserController {
* Only Admin or user itself will get user autoExec code. * Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName. * @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier * @param userId The user's identifier
* @example userId 1234 * @example userId "userIdString"
*/ */
@Get('{userId}') @Get('{uid}')
public async getUser( public async getUser(
@Request() req: express.Request, @Request() req: express.Request,
@Path() userId: number @Path() uid: string
): Promise<UserDetailsResponse> { ): Promise<UserDetailsResponse> {
const { MODE } = process.env const { MODE } = process.env
if (MODE === ModeType.Desktop) return getDesktopAutoExec() if (MODE === ModeType.Desktop) return getDesktopAutoExec()
const { user } = req const { user } = req
const getAutoExec = user!.isAdmin || user!.userId == userId const getAutoExec = user!.isAdmin || user!.userId === uid
return getUser({ id: userId }, getAutoExec) return getUser({ _id: uid }, getAutoExec)
} }
/** /**
@@ -133,7 +129,7 @@ export class UserController {
* @example username "johnSnow01" * @example username "johnSnow01"
*/ */
@Example<UserDetailsResponse>({ @Example<UserDetailsResponse>({
id: 1234, uid: 'userIdString',
displayName: 'John Snow', displayName: 'John Snow',
username: 'johnSnow01', username: 'johnSnow01',
isAdmin: false, isAdmin: false,
@@ -158,7 +154,7 @@ export class UserController {
* @example userId "1234" * @example userId "1234"
*/ */
@Example<UserDetailsResponse>({ @Example<UserDetailsResponse>({
id: 1234, uid: 'userIdString',
displayName: 'John Snow', displayName: 'John Snow',
username: 'johnSnow01', username: 'johnSnow01',
isAdmin: false, isAdmin: false,
@@ -166,7 +162,7 @@ export class UserController {
}) })
@Patch('{userId}') @Patch('{userId}')
public async updateUser( public async updateUser(
@Path() userId: number, @Path() userId: string,
@Body() body: UserPayload @Body() body: UserPayload
): Promise<UserDetailsResponse> { ): Promise<UserDetailsResponse> {
const { MODE } = process.env const { MODE } = process.env
@@ -174,7 +170,7 @@ export class UserController {
if (MODE === ModeType.Desktop) if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '') return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser({ id: userId }, body) return updateUser({ _id: userId }, body)
} }
/** /**
@@ -198,18 +194,16 @@ export class UserController {
*/ */
@Delete('{userId}') @Delete('{userId}')
public async deleteUser( public async deleteUser(
@Path() userId: number, @Path() userId: string,
@Body() body: { password?: string }, @Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false @Query() @Hidden() isAdmin: boolean = false
) { ) {
return deleteUser({ id: userId }, isAdmin, body) return deleteUser({ _id: userId }, isAdmin, body)
} }
} }
const getAllUsers = async (): Promise<UserResponse[]> => const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({}) await User.find({}).select('uid username displayName isAdmin').exec()
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
.exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => { const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive, autoExec } = data const { displayName, username, password, isAdmin, isActive, autoExec } = data
@@ -237,8 +231,17 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const savedUser = await user.save() const savedUser = await user.save()
const groupController = new GroupController()
const allUsersGroup = await groupController
.getGroupByName(ALL_USERS_GROUP.name)
.catch(() => {})
if (allUsersGroup) {
await groupController.addUserToGroup(allUsersGroup.uid, savedUser.uid)
}
return { return {
id: savedUser.id, uid: savedUser.uid,
displayName: savedUser.displayName, displayName: savedUser.displayName,
username: savedUser.username, username: savedUser.username,
isActive: savedUser.isActive, isActive: savedUser.isActive,
@@ -247,8 +250,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
} }
} }
interface GetUserBy { export interface GetUserBy {
id?: number _id?: string
username?: string username?: string
} }
@@ -258,10 +261,10 @@ const getUser = async (
): Promise<UserDetailsResponse> => { ): Promise<UserDetailsResponse> => {
const user = (await User.findOne( const user = (await User.findOne(
findBy, findBy,
`id displayName username isActive isAdmin autoExec -_id` `uid displayName username isActive isAdmin autoExec`
).populate( ).populate(
'groups', 'groups',
'groupId name description -_id' 'uid name description'
)) as unknown as UserDetailsResponse )) as unknown as UserDetailsResponse
if (!user) if (!user)
@@ -271,12 +274,12 @@ const getUser = async (
} }
return { return {
id: user.id, uid: user.uid,
displayName: user.displayName, displayName: user.displayName,
username: user.username, username: user.username,
isActive: user.isActive, isActive: user.isActive,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined, autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
groups: user.groups groups: user.groups
} }
} }
@@ -284,7 +287,7 @@ const getUser = async (
const getDesktopAutoExec = async () => { const getDesktopAutoExec = async () => {
return { return {
...desktopUser, ...desktopUser,
id: desktopUser.userId, uid: desktopUser.userId,
autoExec: await getUserAutoExec() autoExec: await getUserAutoExec()
} }
} }
@@ -320,8 +323,8 @@ const updateUser = async (
const usernameExist = await User.findOne({ username }) const usernameExist = await User.findOne({ username })
if (usernameExist) { if (usernameExist) {
if ( if (
(findBy.id && usernameExist.id != findBy.id) || (findBy._id && usernameExist.uid !== findBy._id) ||
(findBy.username && usernameExist.username != findBy.username) (findBy.username && usernameExist.username !== findBy.username)
) )
throw { throw {
code: 409, code: 409,
@@ -341,11 +344,11 @@ const updateUser = async (
if (!updatedUser) if (!updatedUser)
throw { throw {
code: 404, code: 404,
message: `Unable to find user with ${findBy.id || findBy.username}` message: `Unable to find user with ${findBy._id || findBy.username}`
} }
return { return {
id: updatedUser.id, uid: updatedUser.uid,
username: updatedUser.username, username: updatedUser.username,
displayName: updatedUser.displayName, displayName: updatedUser.displayName,
isAdmin: updatedUser.isAdmin, isAdmin: updatedUser.isAdmin,
@@ -358,7 +361,7 @@ const updateDesktopAutoExec = async (autoExec: string) => {
await updateUserAutoExec(autoExec) await updateUserAutoExec(autoExec)
return { return {
...desktopUser, ...desktopUser,
id: desktopUser.userId, uid: desktopUser.userId,
autoExec autoExec
} }
} }

View File

@@ -1,13 +1,14 @@
import path from 'path' import path from 'path'
import express from 'express' import express from 'express'
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa' import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
import { readFile } from '@sasjs/utils' import { readFile, convertSecondsToHms } from '@sasjs/utils'
import User from '../model/User' import User from '../model/User'
import Client from '../model/Client' import Client from '../model/Client'
import { import {
getWebBuildFolder, getWebBuildFolder,
generateAuthCode, generateAuthCode,
RateLimiter,
AuthProviderType, AuthProviderType,
LDAPClient LDAPClient
} from '../utils' } from '../utils'
@@ -83,19 +84,38 @@ const login = async (
) => { ) => {
// Authenticate User // Authenticate User
const user = await User.findOne({ username }) const user = await User.findOne({ username })
if (!user) throw new Error('Username is not found.')
if ( let validPass = false
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
user.authProvider === AuthProviderType.LDAP if (user) {
) { if (
const ldapClient = await LDAPClient.init() process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
await ldapClient.verifyUser(username, password) user.authProvider === AuthProviderType.LDAP
} else { ) {
const validPass = user.comparePassword(password) const ldapClient = await LDAPClient.init()
if (!validPass) throw new Error('Invalid password.') validPass = await ldapClient
.verifyUser(username, password)
.catch(() => false)
} else {
validPass = user.comparePassword(password)
}
} }
// code to prevent brute force attack
const rateLimiter = RateLimiter.getInstance()
if (!validPass) {
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
}
if (!user) throw errors.userNotFound
if (!validPass) throw errors.invalidPassword
// Reset on successful authorization
rateLimiter.resetOnSuccess(req.ip, user.username)
req.session.loggedIn = true req.session.loggedIn = true
req.session.user = { req.session.user = {
userId: user.id, userId: user.id,
@@ -104,7 +124,8 @@ const login = async (
displayName: user.displayName, displayName: user.displayName,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isActive: user.isActive, isActive: user.isActive,
autoExec: user.autoExec autoExec: user.autoExec,
needsToUpdatePassword: user.needsToUpdatePassword
} }
return { return {
@@ -113,7 +134,8 @@ const login = async (
id: user.id, id: user.id,
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
isAdmin: user.isAdmin isAdmin: user.isAdmin,
needsToUpdatePassword: user.needsToUpdatePassword
} }
} }
} }
@@ -170,3 +192,18 @@ interface AuthorizeResponse {
*/ */
code: string code: string
} }
const errors = {
invalidPassword: {
code: 401,
message: 'Invalid Password.'
},
userNotFound: {
code: 401,
message: 'Username is not found.'
},
tooManyRequests: (seconds: number) => ({
code: 429,
message: `Too Many Requests! Retry after ${convertSecondsToHms(seconds)}`
})
}

View File

@@ -76,12 +76,13 @@ const authenticateToken = async (
const { MODE } = process.env const { MODE } = process.env
if (MODE === ModeType.Desktop) { if (MODE === ModeType.Desktop) {
req.user = { req.user = {
userId: 1234, userId: '1234',
clientId: 'desktopModeClientId', clientId: 'desktopModeClientId',
username: 'desktopModeUsername', username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName', displayName: 'desktopModeDisplayName',
isAdmin: true, isAdmin: true,
isActive: true isActive: true,
needsToUpdatePassword: false
} }
req.accessToken = 'desktopModeAccessToken' req.accessToken = 'desktopModeAccessToken'
return next() return next()

View File

@@ -5,7 +5,7 @@ import {
PermissionSettingForRoute, PermissionSettingForRoute,
PermissionType PermissionType
} from '../controllers/permission' } from '../controllers/permission'
import { getPath, isPublicRoute } from '../utils' import { getPath, isPublicRoute, TopLevelRoutes } from '../utils'
export const authorize: RequestHandler = async (req, res, next) => { export const authorize: RequestHandler = async (req, res, next) => {
const { user } = req const { user } = req
@@ -18,10 +18,13 @@ export const authorize: RequestHandler = async (req, res, next) => {
// no need to check for permissions when route is Public // no need to check for permissions when route is Public
if (await isPublicRoute(req)) return next() if (await isPublicRoute(req)) return next()
const dbUser = await User.findOne({ id: user.userId }) const dbUser = await User.findOne({ _id: user.userId })
if (!dbUser) return res.sendStatus(401) if (!dbUser) return res.sendStatus(401)
const path = getPath(req) const path = getPath(req)
const { baseUrl } = req
const topLevelRoute =
TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl
// find permission w.r.t user // find permission w.r.t user
const permission = await Permission.findOne({ const permission = await Permission.findOne({
@@ -35,6 +38,21 @@ export const authorize: RequestHandler = async (req, res, next) => {
else return res.sendStatus(401) else return res.sendStatus(401)
} }
// find permission w.r.t user on top level
const topLevelPermission = await Permission.findOne({
path: topLevelRoute,
type: PermissionType.route,
user: dbUser._id
})
if (topLevelPermission) {
if (topLevelPermission.setting === PermissionSettingForRoute.grant)
return next()
else return res.sendStatus(401)
}
let isPermissionDenied = false
// find permission w.r.t user's groups // find permission w.r.t user's groups
for (const group of dbUser.groups) { for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({ const groupPermission = await Permission.findOne({
@@ -42,8 +60,28 @@ export const authorize: RequestHandler = async (req, res, next) => {
type: PermissionType.route, type: PermissionType.route,
group group
}) })
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next() if (groupPermission) {
if (groupPermission.setting === PermissionSettingForRoute.grant) {
return next()
} else {
isPermissionDenied = true
}
}
} }
if (!isPermissionDenied) {
// find permission w.r.t user's groups on top level
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({
path: topLevelRoute,
type: PermissionType.route,
group
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next()
}
}
return res.sendStatus(401) return res.sendStatus(401)
} }

View File

@@ -0,0 +1,22 @@
import { RequestHandler } from 'express'
import { convertSecondsToHms } from '@sasjs/utils'
import { RateLimiter } from '../utils'
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
const ip = req.ip
const username = req.body.username
const rateLimiter = RateLimiter.getInstance()
const retrySecs = await rateLimiter.check(ip, username)
if (retrySecs > 0) {
res
.status(429)
.send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`)
return
}
next()
}

View File

@@ -28,10 +28,11 @@ export const desktopRestrict: RequestHandler = (req, res, next) => {
} }
export const desktopUser: RequestUser = { export const desktopUser: RequestUser = {
userId: 12345, userId: '12345',
clientId: 'desktop_app', clientId: 'desktop_app',
username: userInfo().username, username: userInfo().username,
displayName: userInfo().username, displayName: userInfo().username,
isAdmin: true, isAdmin: true,
isActive: true isActive: true,
needsToUpdatePassword: false
} }

View File

@@ -4,3 +4,4 @@ export * from './csrfProtection'
export * from './desktop' export * from './desktop'
export * from './verifyAdmin' export * from './verifyAdmin'
export * from './verifyAdminIfNeeded' export * from './verifyAdminIfNeeded'
export * from './bruteForceProtection'

View File

@@ -8,8 +8,8 @@ export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
if (!user?.isAdmin) { if (!user?.isAdmin) {
let adminAccountRequired: boolean = true let adminAccountRequired: boolean = true
if (req.params.userId) { if (req.params.uid) {
adminAccountRequired = user?.userId !== parseInt(req.params.userId) adminAccountRequired = user?.userId !== req.params.uid
} else if (req.params.username) { } else if (req.params.username) {
adminAccountRequired = user?.username !== req.params.username adminAccountRequired = user?.username !== req.params.username
} }

View File

@@ -1,10 +1,9 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose' import { Schema, model, Document, Model } from 'mongoose'
import { GroupDetailsResponse } from '../controllers' import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User' import User, { IUser } from './User'
import { AuthProviderType } from '../utils' import { AuthProviderType } from '../utils'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export const PUBLIC_GROUP_NAME = 'Public' export const PUBLIC_GROUP_NAME = 'public'
export interface GroupPayload { export interface GroupPayload {
/** /**
@@ -25,10 +24,12 @@ export interface GroupPayload {
} }
interface IGroupDocument extends GroupPayload, Document { interface IGroupDocument extends GroupPayload, Document {
groupId: number
isActive: boolean isActive: boolean
users: Schema.Types.ObjectId[] users: Schema.Types.ObjectId[]
authProvider?: AuthProviderType authProvider?: AuthProviderType
// Declare virtual properties as read-only properties
readonly uid: string
} }
interface IGroup extends IGroupDocument { interface IGroup extends IGroupDocument {
@@ -38,32 +39,46 @@ interface IGroup extends IGroupDocument {
} }
interface IGroupModel extends Model<IGroup> {} interface IGroupModel extends Model<IGroup> {}
const groupSchema = new Schema<IGroupDocument>({ const opts = {
name: { toJSON: {
type: String, virtuals: true,
required: true, transform: function (doc: any, ret: any, options: any) {
unique: true delete ret._id
delete ret.id
return ret
}
}
}
const groupSchema = new Schema<IGroupDocument>(
{
name: {
type: String,
required: true,
unique: true
},
description: {
type: String,
default: 'Group description.'
},
authProvider: {
type: String,
enum: AuthProviderType
},
isActive: {
type: Boolean,
default: true
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
}, },
description: { opts
type: String, )
default: 'Group description.'
}, groupSchema.virtual('uid').get(function () {
authProvider: { return this._id.toString()
type: String,
enum: AuthProviderType
},
isActive: {
type: Boolean,
default: true
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
}) })
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
// Hooks
groupSchema.post('save', function (group: IGroup, next: Function) { groupSchema.post('save', function (group: IGroup, next: Function) {
group.populate('users', 'id username displayName -_id').then(function () { group.populate('users', 'uid username displayName').then(function () {
next() next()
}) })
}) })

View File

@@ -1,5 +1,4 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose' import { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
import { PermissionDetailsResponse } from '../controllers' import { PermissionDetailsResponse } from '../controllers'
interface GetPermissionBy { interface GetPermissionBy {
@@ -11,9 +10,11 @@ interface IPermissionDocument extends Document {
path: string path: string
type: string type: string
setting: string setting: string
permissionId: number
user: Schema.Types.ObjectId user: Schema.Types.ObjectId
group: Schema.Types.ObjectId group: Schema.Types.ObjectId
// Declare virtual properties as read-only properties
readonly uid: string
} }
interface IPermission extends IPermissionDocument {} interface IPermission extends IPermissionDocument {}
@@ -22,44 +23,54 @@ interface IPermissionModel extends Model<IPermission> {
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]> get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
} }
const permissionSchema = new Schema<IPermissionDocument>({ const opts = {
path: { toJSON: {
type: String, virtuals: true,
required: true transform: function (doc: any, ret: any, options: any) {
}, delete ret._id
type: { delete ret.id
type: String, return ret
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' }) 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' }
},
opts
)
permissionSchema.virtual('uid').get(function () {
return this._id.toString()
})
// Static Methods // Static Methods
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise< permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
PermissionDetailsResponse[] PermissionDetailsResponse[]
> { > {
return (await this.find(getBy) return (await this.find(getBy)
.select({ .select('uid path type setting')
_id: 0, .populate({ path: 'user', select: 'uid username displayName isAdmin' })
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({ .populate({
path: 'group', path: 'group',
select: 'groupId name description -_id', select: 'uid name description',
populate: { populate: {
path: 'users', path: 'users',
select: 'id username displayName isAdmin -_id', select: 'uid username displayName isAdmin',
options: { limit: 15 } options: { limit: 15 }
} }
})) as unknown as PermissionDetailsResponse[] })) as unknown as PermissionDetailsResponse[]

View File

@@ -1,5 +1,4 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose' import { Schema, model, Document, Model, ObjectId } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { AuthProviderType } from '../utils' import { AuthProviderType } from '../utils'
@@ -37,13 +36,16 @@ export interface UserPayload {
interface IUserDocument extends UserPayload, Document { interface IUserDocument extends UserPayload, Document {
_id: Schema.Types.ObjectId _id: Schema.Types.ObjectId
id: number
isAdmin: boolean isAdmin: boolean
isActive: boolean isActive: boolean
needsToUpdatePassword: boolean
autoExec: string autoExec: string
groups: Schema.Types.ObjectId[] groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }] tokens: [{ [key: string]: string }]
authProvider?: AuthProviderType authProvider?: AuthProviderType
// Declare virtual properties as read-only properties
readonly uid: string
} }
export interface IUser extends IUserDocument { export interface IUser extends IUserDocument {
@@ -54,55 +56,75 @@ export interface IUser extends IUserDocument {
interface IUserModel extends Model<IUser> { interface IUserModel extends Model<IUser> {
hashPassword(password: string): string hashPassword(password: string): string
} }
const opts = {
const userSchema = new Schema<IUserDocument>({ toJSON: {
displayName: { virtuals: true,
type: String, transform: function (doc: any, ret: any, options: any) {
required: true delete ret._id
}, delete ret.id
username: { return ret
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
authProvider: {
type: String,
enum: AuthProviderType
},
isAdmin: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
clientId: {
type: String,
required: true
},
accessToken: {
type: String,
required: true
},
refreshToken: {
type: String,
required: true
}
} }
] }
}
const userSchema = new Schema<IUserDocument>(
{
displayName: {
type: String,
required: true
},
username: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
authProvider: {
type: String,
enum: AuthProviderType
},
isAdmin: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
needsToUpdatePassword: {
type: Boolean,
default: true
},
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
clientId: {
type: String,
required: true
},
accessToken: {
type: String,
required: true
},
refreshToken: {
type: String,
required: true
}
}
]
},
opts
)
userSchema.virtual('uid').get(function () {
return this._id.toString()
}) })
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
// Static Methods // Static Methods
userSchema.static('hashPassword', (password: string): string => { userSchema.static('hashPassword', (password: string): string => {

View File

@@ -7,12 +7,28 @@ import {
authenticateRefreshToken authenticateRefreshToken
} from '../../middlewares' } from '../../middlewares'
import { tokenValidation } from '../../utils' import { tokenValidation, updatePasswordValidation } from '../../utils'
import { InfoJWT } from '../../types' import { InfoJWT } from '../../types'
const authRouter = express.Router() const authRouter = express.Router()
const controller = new AuthController() 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) => { authRouter.post('/token', async (req, res) => {
const { error, value: body } = tokenValidation(req.body) const { error, value: body } = tokenValidation(req.body)
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)

View File

@@ -1,6 +1,7 @@
import express from 'express' import express from 'express'
import { ClientController } from '../../controllers' import { ClientController } from '../../controllers'
import { registerClientValidation } from '../../utils' import { registerClientValidation } from '../../utils'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
const clientRouter = express.Router() const clientRouter = express.Router()
@@ -17,4 +18,19 @@ clientRouter.post('/', async (req, res) => {
} }
}) })
clientRouter.get(
'/',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const controller = new ClientController()
try {
const response = await controller.getAllClients()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
export default clientRouter export default clientRouter

View File

@@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import { runCodeValidation } from '../../utils' import { runCodeValidation, triggerCodeValidation } from '../../utils'
import { CodeController } from '../../controllers/' import { CodeController } from '../../controllers/'
const runRouter = express.Router() const runRouter = express.Router()
@@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => {
} }
}) })
runRouter.post('/trigger', async (req, res) => {
const { error, value: body } = triggerCodeValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.triggerCode(req, body)
res.status(200)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
export default runRouter export default runRouter

View File

@@ -1,7 +1,11 @@
import express from 'express' import express from 'express'
import { GroupController } from '../../controllers/' import { GroupController } from '../../controllers/'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares' import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import { getGroupValidation, registerGroupValidation } from '../../utils' import {
getGroupValidation,
registerGroupValidation,
uidValidation
} from '../../utils'
const groupRouter = express.Router() const groupRouter = express.Router()
@@ -33,12 +37,15 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
} }
}) })
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => { groupRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
const { groupId } = req.params const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.getGroup(parseInt(groupId)) const response = await controller.getGroup(uid)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -56,7 +63,7 @@ groupRouter.get(
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.getGroupByGroupName(name) const response = await controller.getGroupByName(name)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -65,18 +72,15 @@ groupRouter.get(
) )
groupRouter.post( groupRouter.post(
'/:groupId/:userId', '/:groupUid/:userUid',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req, res) => { async (req, res) => {
const { groupId, userId } = req.params const { groupUid, userUid } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.addUserToGroup( const response = await controller.addUserToGroup(groupUid, userUid)
parseInt(groupId),
parseInt(userId)
)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -85,18 +89,15 @@ groupRouter.post(
) )
groupRouter.delete( groupRouter.delete(
'/:groupId/:userId', '/:groupUid/:userUid',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req, res) => { async (req, res) => {
const { groupId, userId } = req.params const { groupUid, userUid } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.removeUserFromGroup( const response = await controller.removeUserFromGroup(groupUid, userUid)
parseInt(groupId),
parseInt(userId)
)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -105,15 +106,18 @@ groupRouter.delete(
) )
groupRouter.delete( groupRouter.delete(
'/:groupId', '/:uid',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req, res) => { async (req, res) => {
const { groupId } = req.params const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const controller = new GroupController() const controller = new GroupController()
try { try {
await controller.deleteGroup(parseInt(groupId)) await controller.deleteGroup(uid)
res.status(200).send('Group Deleted!') res.status(200).send('Group Deleted!')
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)

View File

@@ -3,6 +3,7 @@ import { PermissionController } from '../../controllers/'
import { verifyAdmin } from '../../middlewares' import { verifyAdmin } from '../../middlewares'
import { import {
registerPermissionValidation, registerPermissionValidation,
uidValidation,
updatePermissionValidation updatePermissionValidation
} from '../../utils' } from '../../utils'
@@ -34,14 +35,17 @@ permissionRouter.post('/', verifyAdmin, async (req, res) => {
} }
}) })
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => { permissionRouter.patch('/:uid', verifyAdmin, async (req: any, res) => {
const { permissionId } = req.params const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
const { error, value: body } = updatePermissionValidation(req.body) const { error, value: body } = updatePermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
try { try {
const response = await controller.updatePermission(permissionId, body) const response = await controller.updatePermission(uid, body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
const statusCode = err.code const statusCode = err.code
@@ -50,20 +54,18 @@ permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
} }
}) })
permissionRouter.delete( permissionRouter.delete('/:uid', verifyAdmin, async (req: any, res) => {
'/:permissionId', const { error: uidError, value: params } = uidValidation(req.params)
verifyAdmin, if (uidError) return res.status(400).send(uidError.details[0].message)
async (req: any, res) => {
const { permissionId } = req.params
try { const { uid } = params
await controller.deletePermission(permissionId) try {
res.status(200).send('Permission Deleted!') await controller.deletePermission(uid)
} catch (err: any) { res.status(200).send('Permission Deleted!')
const statusCode = err.code } catch (err: any) {
delete err.code const statusCode = err.code
res.status(statusCode).send(err.message) delete err.code
} res.status(statusCode).send(err.message)
} }
) })
export default permissionRouter export default permissionRouter

View File

@@ -13,6 +13,7 @@ import {
generateAccessToken, generateAccessToken,
generateAuthCode, generateAuthCode,
generateRefreshToken, generateRefreshToken,
randomBytesHexString,
saveTokensInDB, saveTokensInDB,
verifyTokenInDB verifyTokenInDB
} from '../../../utils' } from '../../../utils'
@@ -20,7 +21,6 @@ import {
const clientId = 'someclientID' const clientId = 'someclientID'
const clientSecret = 'someclientSecret' const clientSecret = 'someclientSecret'
const user = { const user = {
id: 1234,
displayName: 'Test User', displayName: 'Test User',
username: 'testUsername', username: 'testUsername',
password: '87654321', password: '87654321',
@@ -52,7 +52,7 @@ describe('auth', () => {
describe('token', () => { describe('token', () => {
const userInfo: InfoJWT = { const userInfo: InfoJWT = {
clientId, clientId,
userId: user.id userId: randomBytesHexString(12)
} }
beforeAll(async () => { beforeAll(async () => {
await userController.createUser(user) await userController.createUser(user)
@@ -151,10 +151,10 @@ describe('auth', () => {
currentUser = await userController.createUser(user) currentUser = await userController.createUser(user)
refreshToken = generateRefreshToken({ refreshToken = generateRefreshToken({
clientId, clientId,
userId: currentUser.id userId: currentUser.uid
}) })
await saveTokensInDB( await saveTokensInDB(
currentUser.id, currentUser.uid,
clientId, clientId,
'accessToken', 'accessToken',
refreshToken refreshToken
@@ -202,11 +202,11 @@ describe('auth', () => {
currentUser = await userController.createUser(user) currentUser = await userController.createUser(user)
accessToken = generateAccessToken({ accessToken = generateAccessToken({
clientId, clientId,
userId: currentUser.id userId: currentUser.uid
}) })
await saveTokensInDB( await saveTokensInDB(
currentUser.id, currentUser.uid,
clientId, clientId,
accessToken, accessToken,
'refreshToken' 'refreshToken'

View File

@@ -5,6 +5,7 @@ import request from 'supertest'
import appPromise from '../../../app' import appPromise from '../../../app'
import { UserController, ClientController } from '../../../controllers/' import { UserController, ClientController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils' import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../../../model/Client'
const client = { const client = {
clientId: 'someclientID', clientId: 'someclientID',
@@ -26,6 +27,7 @@ describe('client', () => {
let app: Express let app: Express
let con: Mongoose let con: Mongoose
let mongoServer: MongoMemoryServer let mongoServer: MongoMemoryServer
let adminAccessToken: string
const userController = new UserController() const userController = new UserController()
const clientController = new ClientController() const clientController = new ClientController()
@@ -34,6 +36,18 @@ describe('client', () => {
mongoServer = await MongoMemoryServer.create() mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri()) con = await mongoose.connect(mongoServer.getUri())
const dbUser = await userController.createUser(adminUser)
adminAccessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.uid
})
await saveTokensInDB(
dbUser.uid,
client.clientId,
adminAccessToken,
'refreshToken'
)
}) })
afterAll(async () => { afterAll(async () => {
@@ -43,22 +57,6 @@ describe('client', () => {
}) })
describe('create', () => { describe('create', () => {
let adminAccessToken: string
beforeAll(async () => {
const dbUser = await userController.createUser(adminUser)
adminAccessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
})
await saveTokensInDB(
dbUser.id,
client.clientId,
adminAccessToken,
'refreshToken'
)
})
afterEach(async () => { afterEach(async () => {
const collections = mongoose.connection.collections const collections = mongoose.connection.collections
const collection = collections['clients'] const collection = collections['clients']
@@ -97,10 +95,10 @@ describe('client', () => {
const dbUser = await userController.createUser(user) const dbUser = await userController.createUser(user)
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
clientId: client.clientId, clientId: client.clientId,
userId: dbUser.id userId: dbUser.uid
}) })
await saveTokensInDB( await saveTokensInDB(
dbUser.id, dbUser.uid,
client.clientId, client.clientId,
accessToken, accessToken,
'refreshToken' 'refreshToken'
@@ -157,4 +155,80 @@ describe('client', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
}) })
describe('get', () => {
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['clients']
await collection.deleteMany({})
})
it('should respond with an array of all clients', async () => {
await clientController.createClient(newClient)
await clientController.createClient({
clientId: 'clientID',
clientSecret: 'clientSecret'
})
const res = await request(app)
.get('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const expected = [
{
clientId: 'newClientID',
clientSecret: 'newClientSecret',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
},
{
clientId: 'clientID',
clientSecret: 'clientSecret',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
}
]
expect(res.body).toEqual(expected)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get('/SASjsApi/client').send().expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbideen if access token is not of an admin account', async () => {
const user = {
displayName: 'User 2',
username: 'username2',
password: '12345678',
isAdmin: false,
isActive: true
}
const dbUser = await userController.createUser(user)
const accessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.uid
})
await saveTokensInDB(
dbUser.uid,
client.clientId,
accessToken,
'refreshToken'
)
const res = await request(app)
.get('/SASjsApi/client')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
})
}) })

View File

@@ -71,31 +71,31 @@ describe('drive', () => {
con = await mongoose.connect(mongoServer.getUri()) con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id) accessToken = await generateAndSaveToken(dbUser.uid)
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/deploy', path: '/SASjsApi/drive/deploy',
principalId: dbUser.id principalId: dbUser.uid
}) })
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/deploy/upload', path: '/SASjsApi/drive/deploy/upload',
principalId: dbUser.id principalId: dbUser.uid
}) })
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/file', path: '/SASjsApi/drive/file',
principalId: dbUser.id principalId: dbUser.uid
}) })
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/folder', path: '/SASjsApi/drive/folder',
principalId: dbUser.id principalId: dbUser.uid
}) })
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/rename', path: '/SASjsApi/drive/rename',
principalId: dbUser.id principalId: dbUser.uid
}) })
}) })
@@ -1197,7 +1197,7 @@ const getExampleService = (): ServiceMember =>
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember) ((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
.members[0] as ServiceMember .members[0] as ServiceMember
const generateAndSaveToken = async (userId: number) => { const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({ const adminAccessToken = generateAccessToken({
clientId, clientId,
userId userId

View File

@@ -11,6 +11,7 @@ import {
} from '../../../utils' } from '../../../utils'
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group' import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
import User from '../../../model/User' import User from '../../../model/User'
import { randomBytes } from 'crypto'
const clientId = 'someclientID' const clientId = 'someclientID'
const adminUser = { const adminUser = {
@@ -75,7 +76,7 @@ describe('group', () => {
.send(group) .send(group)
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -155,7 +156,7 @@ describe('group', () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`) .delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -174,17 +175,17 @@ describe('group', () => {
username: 'deletegroup2' username: 'deletegroup2'
}) })
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id) await groupController.addUserToGroup(dbGroup.uid, dbUser1.uid)
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id) await groupController.addUserToGroup(dbGroup.uid, dbUser2.uid)
await request(app) await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`) .delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
const res1 = await request(app) const res1 = await request(app)
.get(`/SASjsApi/user/${dbUser1.id}`) .get(`/SASjsApi/user/${dbUser1.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -192,7 +193,7 @@ describe('group', () => {
expect(res1.body.groups).toEqual([]) expect(res1.body.groups).toEqual([])
const res2 = await request(app) const res2 = await request(app)
.get(`/SASjsApi/user/${dbUser2.id}`) .get(`/SASjsApi/user/${dbUser2.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -201,8 +202,10 @@ describe('group', () => {
}) })
it('should respond with Not Found if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/1234`) .delete(`/SASjsApi/group/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -229,7 +232,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`) .delete(`/SASjsApi/group/${dbGroup.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send() .send()
.expect(401) .expect(401)
@@ -245,15 +248,15 @@ describe('group', () => {
}) })
it('should respond with group', async () => { it('should respond with group', async () => {
const { groupId } = await groupController.createGroup(group) const { uid } = await groupController.createGroup(group)
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/group/${groupId}`) .get(`/SASjsApi/group/${uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -266,15 +269,15 @@ describe('group', () => {
username: 'get' + user.username username: 'get' + user.username
}) })
const { groupId } = await groupController.createGroup(group) const { uid } = await groupController.createGroup(group)
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/group/${groupId}`) .get(`/SASjsApi/group/${uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -292,8 +295,10 @@ describe('group', () => {
}) })
it('should respond with Not Found if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.get('/SASjsApi/group/1234') .get(`/SASjsApi/group/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -312,7 +317,7 @@ describe('group', () => {
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -333,7 +338,7 @@ describe('group', () => {
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -379,7 +384,7 @@ describe('group', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
groupId: expect.anything(), uid: expect.anything(),
name: group.name, name: group.name,
description: group.description description: group.description
} }
@@ -401,7 +406,7 @@ describe('group', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
groupId: expect.anything(), uid: expect.anything(),
name: group.name, name: group.name,
description: group.description description: group.description
} }
@@ -426,18 +431,18 @@ describe('group', () => {
const dbUser = await userController.createUser(user) const dbUser = await userController.createUser(user)
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([ expect(res.body.users).toEqual([
{ {
id: expect.anything(), uid: expect.anything(),
username: user.username, username: user.username,
displayName: user.displayName displayName: user.displayName
} }
@@ -452,20 +457,20 @@ describe('group', () => {
}) })
await request(app) await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`) .get(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groups).toEqual([ expect(res.body.groups).toEqual([
{ {
groupId: expect.anything(), uid: expect.anything(),
name: group.name, name: group.name,
description: group.description description: group.description
} }
@@ -478,21 +483,21 @@ describe('group', () => {
...user, ...user,
username: 'addUserRandomUser' username: 'addUserRandomUser'
}) })
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id) await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([ expect(res.body.users).toEqual([
{ {
id: expect.anything(), uid: expect.anything(),
username: 'addUserRandomUser', username: 'addUserRandomUser',
displayName: user.displayName displayName: user.displayName
} }
@@ -526,8 +531,10 @@ describe('group', () => {
}) })
it('should respond with Not Found if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.post('/SASjsApi/group/123/123') .post(`/SASjsApi/group/${hexValue}/123`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -538,8 +545,10 @@ describe('group', () => {
it('should respond with Not Found if userId is incorrect', async () => { it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/123`) .post(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -556,7 +565,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(400) .expect(400)
@@ -577,7 +586,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(405) .expect(405)
@@ -596,7 +605,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(405) .expect(405)
@@ -618,15 +627,15 @@ describe('group', () => {
...user, ...user,
username: 'removeUserRandomUser' username: 'removeUserRandomUser'
}) })
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id) await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
expect(res.body.groupId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.name).toEqual(group.name) expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description) expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true) expect(res.body.isActive).toEqual(true)
@@ -639,16 +648,16 @@ describe('group', () => {
...user, ...user,
username: 'removeGroupFromUser' username: 'removeGroupFromUser'
}) })
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id) await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
await request(app) await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`) .get(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -667,7 +676,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(405) .expect(405)
@@ -686,7 +695,7 @@ describe('group', () => {
}) })
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) .delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(405) .expect(405)
@@ -723,8 +732,10 @@ describe('group', () => {
}) })
it('should respond with Not Found if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.delete('/SASjsApi/group/123/123') .delete(`/SASjsApi/group/${hexValue}/123`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -735,8 +746,10 @@ describe('group', () => {
it('should respond with Not Found if userId is incorrect', async () => { it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`) .delete(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -752,10 +765,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => { ): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser) const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id) return generateAndSaveToken(dbUser.uid)
} }
const generateAndSaveToken = async (userId: number) => { const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({ const adminAccessToken = generateAccessToken({
clientId, clientId,
userId userId

View File

@@ -17,6 +17,7 @@ import {
PermissionDetailsResponse PermissionDetailsResponse
} from '../../../controllers' } from '../../../controllers'
import { generateAccessToken, saveTokensInDB } from '../../../utils' import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { randomBytes } from 'crypto'
const deployPayload = { const deployPayload = {
appLoc: 'string', appLoc: 'string',
@@ -103,10 +104,10 @@ describe('permission', () => {
const res = await request(app) const res = await request(app)
.post('/SASjsApi/permission') .post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id }) .send({ ...permission, principalId: dbUser.uid })
.expect(200) .expect(200)
expect(res.body.permissionId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.path).toEqual(permission.path) expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type) expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting) expect(res.body.setting).toEqual(permission.setting)
@@ -122,11 +123,11 @@ describe('permission', () => {
.send({ .send({
...permission, ...permission,
principalType: 'group', principalType: 'group',
principalId: dbGroup.groupId principalId: dbGroup.uid
}) })
.expect(200) .expect(200)
expect(res.body.permissionId).toBeTruthy() expect(res.body.uid).toBeTruthy()
expect(res.body.path).toEqual(permission.path) expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type) expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting) expect(res.body.setting).toEqual(permission.setting)
@@ -144,7 +145,7 @@ describe('permission', () => {
}) })
it('should respond with Unauthorized if access token is not of an admin account', async () => { it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.post('/SASjsApi/permission') .post('/SASjsApi/permission')
@@ -281,17 +282,19 @@ describe('permission', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Bad Request if principalId is not a number', async () => { it('should respond with Bad Request if principalId is not a string of 24 hex characters', async () => {
const res = await request(app) const res = await request(app)
.post('/SASjsApi/permission') .post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ .send({
...permission, ...permission,
principalId: 'someCharacters' principalId: randomBytes(10).toString('hex')
}) })
.expect(400) .expect(400)
expect(res.text).toEqual('"principalId" must be a number') expect(res.text).toEqual(
'"principalId" length must be 24 characters long'
)
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
@@ -307,7 +310,7 @@ describe('permission', () => {
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ .send({
...permission, ...permission,
principalId: adminUser.id principalId: adminUser.uid
}) })
.expect(400) .expect(400)
@@ -321,7 +324,7 @@ describe('permission', () => {
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ .send({
...permission, ...permission,
principalId: 123 principalId: randomBytes(12).toString('hex')
}) })
.expect(404) .expect(404)
@@ -336,7 +339,7 @@ describe('permission', () => {
.send({ .send({
...permission, ...permission,
principalType: 'group', principalType: 'group',
principalId: 123 principalId: randomBytes(12).toString('hex')
}) })
.expect(404) .expect(404)
@@ -347,13 +350,13 @@ describe('permission', () => {
it('should respond with Conflict (409) if permission already exists', async () => { it('should respond with Conflict (409) if permission already exists', async () => {
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
principalId: dbUser.id principalId: dbUser.uid
}) })
const res = await request(app) const res = await request(app)
.post('/SASjsApi/permission') .post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id }) .send({ ...permission, principalId: dbUser.uid })
.expect(409) .expect(409)
expect(res.text).toEqual( expect(res.text).toEqual(
@@ -368,7 +371,7 @@ describe('permission', () => {
beforeAll(async () => { beforeAll(async () => {
dbPermission = await permissionController.createPermission({ dbPermission = await permissionController.createPermission({
...permission, ...permission,
principalId: dbUser.id principalId: dbUser.uid
}) })
}) })
@@ -378,7 +381,7 @@ describe('permission', () => {
it('should respond with updated permission', async () => { it('should respond with updated permission', async () => {
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) .patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ setting: PermissionSettingForRoute.deny }) .send({ setting: PermissionSettingForRoute.deny })
.expect(200) .expect(200)
@@ -388,7 +391,7 @@ describe('permission', () => {
it('should respond with Unauthorized if access token is not present', async () => { it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) .patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.send() .send()
.expect(401) .expect(401)
@@ -403,7 +406,7 @@ describe('permission', () => {
}) })
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) .patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send() .send()
.expect(401) .expect(401)
@@ -414,7 +417,7 @@ describe('permission', () => {
it('should respond with Bad Request if setting is missing', async () => { it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) .patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(400) .expect(400)
@@ -425,7 +428,7 @@ describe('permission', () => {
it('should respond with Bad Request if setting is invalid', async () => { it('should respond with Bad Request if setting is invalid', async () => {
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) .patch(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ .send({
setting: 'invalid' setting: 'invalid'
@@ -437,8 +440,9 @@ describe('permission', () => {
}) })
it('should respond with not found (404) if permission with provided id does not exist', async () => { it('should respond with not found (404) if permission with provided id does not exist', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.patch('/SASjsApi/permission/123') .patch(`/SASjsApi/permission/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ .send({
setting: PermissionSettingForRoute.deny setting: PermissionSettingForRoute.deny
@@ -454,10 +458,10 @@ describe('permission', () => {
it('should delete permission', async () => { it('should delete permission', async () => {
const dbPermission = await permissionController.createPermission({ const dbPermission = await permissionController.createPermission({
...permission, ...permission,
principalId: dbUser.id principalId: dbUser.uid
}) })
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`) .delete(`/SASjsApi/permission/${dbPermission?.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -466,8 +470,10 @@ describe('permission', () => {
}) })
it('should respond with not found (404) if permission with provided id does not exists', async () => { it('should respond with not found (404) if permission with provided id does not exists', async () => {
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.delete('/SASjsApi/permission/123') .delete(`/SASjsApi/permission/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -481,12 +487,12 @@ describe('permission', () => {
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/test-1', path: '/test-1',
principalId: dbUser.id principalId: dbUser.uid
}) })
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/test-2', path: '/test-2',
principalId: dbUser.id principalId: dbUser.uid
}) })
}) })
@@ -505,12 +511,12 @@ describe('permission', () => {
...user, ...user,
username: 'get' + user.username username: 'get' + user.username
}) })
const accessToken = await generateAndSaveToken(nonAdminUser.id) const accessToken = await generateAndSaveToken(nonAdminUser.uid)
await permissionController.createPermission({ await permissionController.createPermission({
path: '/test-1', path: '/test-1',
type: PermissionType.route, type: PermissionType.route,
principalType: PrincipalType.user, principalType: PrincipalType.user,
principalId: nonAdminUser.id, principalId: nonAdminUser.uid,
setting: PermissionSettingForRoute.grant setting: PermissionSettingForRoute.grant
}) })
@@ -531,7 +537,7 @@ describe('permission', () => {
await permissionController.createPermission({ await permissionController.createPermission({
...permission, ...permission,
path: '/SASjsApi/drive/deploy', path: '/SASjsApi/drive/deploy',
principalId: dbUser.id principalId: dbUser.uid
}) })
}) })
@@ -551,7 +557,7 @@ describe('permission', () => {
}) })
it('should create files in SASJS drive', async () => { it('should create files in SASJS drive', async () => {
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
await request(app) await request(app)
.get('/SASjsApi/drive/deploy') .get('/SASjsApi/drive/deploy')
@@ -561,7 +567,7 @@ describe('permission', () => {
}) })
it('should respond unauthorized', async () => { it('should respond unauthorized', async () => {
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
await request(app) await request(app)
.get('/SASjsApi/drive/deploy/upload') .get('/SASjsApi/drive/deploy/upload')
@@ -577,10 +583,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => { ): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser) const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id) return generateAndSaveToken(dbUser.uid)
} }
const generateAndSaveToken = async (userId: number) => { const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({ const adminAccessToken = generateAccessToken({
clientId, clientId,
userId userId

View File

@@ -58,12 +58,12 @@ describe('stp', () => {
mongoServer = await MongoMemoryServer.create() mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri()) con = await mongoose.connect(mongoServer.getUri())
const dbUser = await userController.createUser(user) const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id) accessToken = await generateAndSaveToken(dbUser.uid)
await permissionController.createPermission({ await permissionController.createPermission({
path: '/SASjsApi/stp/execute', path: '/SASjsApi/stp/execute',
type: PermissionType.route, type: PermissionType.route,
principalType: PrincipalType.user, principalType: PrincipalType.user,
principalId: dbUser.id, principalId: dbUser.uid,
setting: PermissionSettingForRoute.grant setting: PermissionSettingForRoute.grant
}) })
}) })
@@ -456,7 +456,7 @@ const makeRequestAndAssert = async (
) )
} }
const generateAndSaveToken = async (userId: number) => { const generateAndSaveToken = async (userId: string) => {
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
clientId, clientId,
userId userId

View File

@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto'
import { Express } from 'express' import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose' import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server' import { MongoMemoryServer } from 'mongodb-memory-server'
@@ -101,9 +102,9 @@ describe('user', () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
clientId, clientId,
userId: dbUser.id userId: dbUser.uid
}) })
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken') await saveTokensInDB(dbUser.uid, clientId, accessToken, 'refreshToken')
const res = await request(app) const res = await request(app)
.post('/SASjsApi/user') .post('/SASjsApi/user')
@@ -187,7 +188,7 @@ describe('user', () => {
const newDisplayName = 'My new display Name' const newDisplayName = 'My new display Name'
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`) .patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName }) .send({ ...user, displayName: newDisplayName })
.expect(200) .expect(200)
@@ -200,11 +201,11 @@ describe('user', () => {
it('should respond with updated user when user himself requests', async () => { it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name' const newDisplayName = 'My new display Name'
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`) .patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ .send({
displayName: newDisplayName, displayName: newDisplayName,
@@ -221,11 +222,11 @@ describe('user', () => {
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => { it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name' const newDisplayName = 'My new display Name'
await request(app) await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`) .patch(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName }) .send({ ...user, displayName: newDisplayName })
.expect(400) .expect(400)
@@ -277,10 +278,10 @@ describe('user', () => {
...user, ...user,
username: 'randomUser' username: 'randomUser'
}) })
const accessToken = await generateAndSaveToken(dbUser2.id) const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`) .patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send(user) .send(user)
.expect(401) .expect(401)
@@ -297,7 +298,7 @@ describe('user', () => {
}) })
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`) .patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send({ username: dbUser2.username }) .send({ username: dbUser2.username })
.expect(409) .expect(409)
@@ -325,7 +326,7 @@ describe('user', () => {
it('should respond with updated user when user himself requests', async () => { it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name' const newDisplayName = 'My new display Name'
const res = await request(app) const res = await request(app)
@@ -346,7 +347,7 @@ describe('user', () => {
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => { it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const newDisplayName = 'My new display Name' const newDisplayName = 'My new display Name'
await request(app) await request(app)
@@ -372,10 +373,10 @@ describe('user', () => {
...user, ...user,
username: 'randomUser' username: 'randomUser'
}) })
const accessToken = await generateAndSaveToken(dbUser2.id) const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app) const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`) .patch(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send(user) .send(user)
.expect(401) .expect(401)
@@ -418,7 +419,7 @@ describe('user', () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`) .delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(200) .expect(200)
@@ -428,10 +429,10 @@ describe('user', () => {
it('should respond with OK when user himself requests', async () => { it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`) .delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ password: user.password }) .send({ password: user.password })
.expect(200) .expect(200)
@@ -441,10 +442,10 @@ describe('user', () => {
it('should respond with Bad Request when user himself requests and password is missing', async () => { it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`) .delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send() .send()
.expect(400) .expect(400)
@@ -469,10 +470,10 @@ describe('user', () => {
...user, ...user,
username: 'randomUser' username: 'randomUser'
}) })
const accessToken = await generateAndSaveToken(dbUser2.id) const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/${dbUser1.id}`) .delete(`/SASjsApi/user/${dbUser1.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send(user) .send(user)
.expect(401) .expect(401)
@@ -483,10 +484,10 @@ describe('user', () => {
it('should respond with Unauthorized 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 dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`) .delete(`/SASjsApi/user/${dbUser.uid}`)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ password: 'incorrectpassword' }) .send({ password: 'incorrectpassword' })
.expect(401) .expect(401)
@@ -510,7 +511,7 @@ describe('user', () => {
it('should respond with OK when user himself requests', async () => { it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`) .delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -523,7 +524,7 @@ describe('user', () => {
it('should respond with Bad Request when user himself requests and password is missing', async () => { it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`) .delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -551,7 +552,7 @@ describe('user', () => {
...user, ...user,
username: 'randomUser' username: 'randomUser'
}) })
const accessToken = await generateAndSaveToken(dbUser2.id) const accessToken = await generateAndSaveToken(dbUser2.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser1.username}`) .delete(`/SASjsApi/user/by/username/${dbUser1.username}`)
@@ -565,7 +566,7 @@ describe('user', () => {
it('should respond with Unauthorized 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 dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id) const accessToken = await generateAndSaveToken(dbUser.uid)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`) .delete(`/SASjsApi/user/by/username/${dbUser.username}`)
@@ -592,7 +593,7 @@ describe('user', () => {
it('should respond with user autoExec when same user requests', async () => { it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId) const accessToken = await generateAndSaveToken(userId)
const res = await request(app) const res = await request(app)
@@ -611,7 +612,7 @@ describe('user', () => {
it('should respond with user autoExec when admin user requests', async () => { it('should respond with user autoExec when admin user requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.uid
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/user/${userId}`) .get(`/SASjsApi/user/${userId}`)
@@ -634,7 +635,7 @@ describe('user', () => {
}) })
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.uid
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/user/${userId}`) .get(`/SASjsApi/user/${userId}`)
@@ -652,7 +653,7 @@ describe('user', () => {
it('should respond with user along with associated groups', async () => { it('should respond with user along with associated groups', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId) const accessToken = await generateAndSaveToken(userId)
const group = { const group = {
@@ -661,7 +662,7 @@ describe('user', () => {
} }
const groupController = new GroupController() const groupController = new GroupController()
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id) await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
const res = await request(app) const res = await request(app)
.get(`/SASjsApi/user/${userId}`) .get(`/SASjsApi/user/${userId}`)
@@ -690,8 +691,10 @@ describe('user', () => {
it('should respond with Not Found if userId is incorrect', async () => { it('should respond with Not Found if userId is incorrect', async () => {
await controller.createUser(user) await controller.createUser(user)
const hexValue = randomBytes(12).toString('hex')
const res = await request(app) const res = await request(app)
.get('/SASjsApi/user/1234') .get(`/SASjsApi/user/${hexValue}`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(404) .expect(404)
@@ -703,7 +706,7 @@ describe('user', () => {
describe('by username', () => { describe('by username', () => {
it('should respond with user autoExec when same user requests', async () => { it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.uid
const accessToken = await generateAndSaveToken(userId) const accessToken = await generateAndSaveToken(userId)
const res = await request(app) const res = await request(app)
@@ -803,13 +806,13 @@ describe('user', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
id: expect.anything(), uid: expect.anything(),
username: adminUser.username, username: adminUser.username,
displayName: adminUser.displayName, displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin isAdmin: adminUser.isAdmin
}, },
{ {
id: expect.anything(), uid: expect.anything(),
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
isAdmin: user.isAdmin isAdmin: user.isAdmin
@@ -831,13 +834,13 @@ describe('user', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
id: expect.anything(), uid: expect.anything(),
username: adminUser.username, username: adminUser.username,
displayName: adminUser.displayName, displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin isAdmin: adminUser.isAdmin
}, },
{ {
id: expect.anything(), uid: expect.anything(),
username: 'randomUser', username: 'randomUser',
displayName: user.displayName, displayName: user.displayName,
isAdmin: user.isAdmin isAdmin: user.isAdmin
@@ -859,10 +862,10 @@ const generateSaveTokenAndCreateUser = async (
): Promise<string> => { ): Promise<string> => {
const dbUser = await controller.createUser(someUser ?? adminUser) const dbUser = await controller.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id) return generateAndSaveToken(dbUser.uid)
} }
const generateAndSaveToken = async (userId: number) => { const generateAndSaveToken = async (userId: string) => {
const adminAccessToken = generateAccessToken({ const adminAccessToken = generateAccessToken({
clientId, clientId,
userId userId

View File

@@ -47,72 +47,6 @@ describe('web', () => {
}) })
}) })
describe('SASLogon/login', () => {
let csrfToken: string
beforeAll(async () => {
;({ csrfToken } = await getCSRF(app))
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with successful login', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(200)
expect(res.body.loggedIn).toBeTruthy()
expect(res.body.user).toEqual({
id: expect.any(Number),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
})
})
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', () => { describe('SASLogon/authorize', () => {
let csrfToken: string let csrfToken: string
let authCookies: string let authCookies: string
@@ -183,6 +117,147 @@ describe('web', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
}) })
describe('SASLogon/login', () => {
let csrfToken: string
beforeAll(async () => {
;({ csrfToken } = await getCSRF(app))
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with successful login', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(200)
expect(res.body.loggedIn).toBeTruthy()
expect(res.body.user).toEqual({
id: expect.any(String),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
needsToUpdatePassword: true
})
})
it('should respond with too many requests when attempting with invalid password for a same user too many times', async () => {
await userController.createUser(user)
const promises: request.Test[] = []
const maxConsecutiveFailsByUsernameAndIp = Number(
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
)
Array(maxConsecutiveFailsByUsernameAndIp + 1)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
expect(res.text).toContain('Too Many Requests!')
})
it('should respond with too many requests when attempting with invalid credentials for different users but with same ip too many times', async () => {
await userController.createUser(user)
const promises: request.Test[] = []
const maxWrongAttemptsByIpPerDay = Number(
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
)
Array(maxWrongAttemptsByIpPerDay + 1)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: `user${i}`,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
expect(res.text).toContain('Too Many Requests!')
})
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({})
})
})
}) })
const getCSRF = async (app: Express) => { const getCSRF = async (app: Express) => {

View File

@@ -13,7 +13,11 @@ stpRouter.get('/execute', async (req, res) => {
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
try { try {
const response = await controller.executeGetRequest(req, query._program) const response = await controller.executeGetRequest(
req,
query._program,
query._debug
)
if (response instanceof Buffer) { if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders) res.writeHead(200, (req as any).sasHeaders)

View File

@@ -9,6 +9,7 @@ import {
deleteUserValidation, deleteUserValidation,
getUserValidation, getUserValidation,
registerUserValidation, registerUserValidation,
uidValidation,
updateUserValidation updateUserValidation
} from '../../utils' } from '../../utils'
@@ -56,12 +57,15 @@ userRouter.get(
} }
) )
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { userRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
const { userId } = req.params const { error, value: params } = uidValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { uid } = params
const controller = new UserController() const controller = new UserController()
try { try {
const response = await controller.getUser(req, parseInt(userId)) const response = await controller.getUser(req, uid)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -97,12 +101,16 @@ userRouter.patch(
) )
userRouter.patch( userRouter.patch(
'/:userId', '/:uid',
authenticateAccessToken, authenticateAccessToken,
verifyAdminIfNeeded, verifyAdminIfNeeded,
async (req, res) => { async (req, res) => {
const { user } = req const { user } = req
const { userId } = req.params
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
// only an admin can update `isActive` and `isAdmin` fields // only an admin can update `isActive` and `isAdmin` fields
const { error, value: body } = updateUserValidation(req.body, user!.isAdmin) const { error, value: body } = updateUserValidation(req.body, user!.isAdmin)
@@ -110,7 +118,7 @@ userRouter.patch(
const controller = new UserController() const controller = new UserController()
try { try {
const response = await controller.updateUser(parseInt(userId), body) const response = await controller.updateUser(uid, body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)
@@ -147,12 +155,16 @@ userRouter.delete(
) )
userRouter.delete( userRouter.delete(
'/:userId', '/:uid',
authenticateAccessToken, authenticateAccessToken,
verifyAdminIfNeeded, verifyAdminIfNeeded,
async (req, res) => { async (req, res) => {
const { user } = req const { user } = req
const { userId } = req.params
const { error: uidError, value: params } = uidValidation(req.params)
if (uidError) return res.status(400).send(uidError.details[0].message)
const { uid } = params
// only an admin can delete user without providing password // only an admin can delete user without providing password
const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin) const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin)
@@ -160,7 +172,7 @@ userRouter.delete(
const controller = new UserController() const controller = new UserController()
try { try {
await controller.deleteUser(parseInt(userId), data, user!.isAdmin) await controller.deleteUser(uid, data, user!.isAdmin)
res.status(200).send('Account Deleted!') res.status(200).send('Account Deleted!')
} catch (err: any) { } catch (err: any) {
res.status(err.code).send(err.message) res.status(err.code).send(err.message)

View File

@@ -58,7 +58,7 @@ export const publishAppStream = async (
) )
const sasJsPort = process.env.PORT || 5000 const sasJsPort = process.env.PORT || 5000
console.log( process.logger.info(
'Serving Stream App: ', 'Serving Stream App: ',
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}` `http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
) )

View File

@@ -1,7 +1,11 @@
import express from 'express' import express from 'express'
import { generateCSRFToken } from '../../middlewares' import { generateCSRFToken } from '../../middlewares'
import { WebController } from '../../controllers/web' import { WebController } from '../../controllers/web'
import { authenticateAccessToken, desktopRestrict } from '../../middlewares' import {
authenticateAccessToken,
bruteForceProtection,
desktopRestrict
} from '../../middlewares'
import { authorizeValidation, loginWebValidation } from '../../utils' import { authorizeValidation, loginWebValidation } from '../../utils'
const webRouter = express.Router() const webRouter = express.Router()
@@ -27,17 +31,26 @@ webRouter.get('/', async (req, res) => {
} }
}) })
webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => { webRouter.post(
const { error, value: body } = loginWebValidation(req.body) '/SASLogon/login',
if (error) return res.status(400).send(error.details[0].message) desktopRestrict,
bruteForceProtection,
async (req, res) => {
const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try { try {
const response = await controller.login(req, body) const response = await controller.login(req, body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) if (err instanceof Error) {
res.status(500).send(err.toString())
} else {
res.status(err.code).send(err.message)
}
}
} }
}) )
webRouter.post( webRouter.post(
'/SASLogon/authorize', '/SASLogon/authorize',

View File

@@ -7,11 +7,11 @@ appPromise.then(async (app) => {
const protocol = process.env.PROTOCOL || 'http' const protocol = process.env.PROTOCOL || 'http'
const sasJsPort = process.env.PORT || 5000 const sasJsPort = process.env.PORT || 5000
console.log('PROTOCOL: ', protocol) process.logger.info('PROTOCOL: ', protocol)
if (protocol !== 'https') { if (protocol !== 'https') {
app.listen(sasJsPort, () => { app.listen(sasJsPort, () => {
console.log( process.logger.info(
`⚡️[server]: Server is running at http://localhost:${sasJsPort}` `⚡️[server]: Server is running at http://localhost:${sasJsPort}`
) )
}) })
@@ -20,7 +20,7 @@ appPromise.then(async (app) => {
const httpsServer = createServer({ key, cert, ca }, app) const httpsServer = createServer({ key, cert, ca }, app)
httpsServer.listen(sasJsPort, () => { httpsServer.listen(sasJsPort, () => {
console.log( process.logger.info(
`⚡️[server]: Server is running at https://localhost:${sasJsPort}` `⚡️[server]: Server is running at https://localhost:${sasJsPort}`
) )
}) })

View File

@@ -1,4 +1,4 @@
export interface InfoJWT { export interface InfoJWT {
clientId: string clientId: string
userId: number userId: string
} }

View File

@@ -1,6 +1,6 @@
export interface PreProgramVars { export interface PreProgramVars {
username: string username: string
userId: number userId: string
displayName: string displayName: string
serverUrl: string serverUrl: string
httpHeaders: string[] httpHeaders: string[]

View File

@@ -1,9 +1,10 @@
export interface RequestUser { export interface RequestUser {
userId: number userId: string
clientId: string clientId: string
username: string username: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
isActive: boolean isActive: boolean
needsToUpdatePassword: boolean
autoExec?: string autoExec?: string
} }

View File

@@ -8,4 +8,5 @@ export interface Session {
consumed: boolean consumed: boolean
completed: boolean completed: boolean
crashed?: string crashed?: string
expiresAfterMins?: { mins: number; used: boolean }
} }

View File

@@ -9,6 +9,7 @@ declare namespace NodeJS {
logsLoc: string logsLoc: string
logsUUID: string logsUUID: string
sessionController?: import('../../controllers/internal').SessionController sessionController?: import('../../controllers/internal').SessionController
sasSessionController?: import('../../controllers/internal').SASSessionController
appStreamConfig: import('../').AppStreamConfig appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger logger: import('@sasjs/utils/logger').Logger
runTimes: import('../../utils').RunTimeType[] runTimes: import('../../utils').RunTimeType[]

View File

@@ -36,7 +36,7 @@ export const loadAppStreamConfig = async () => {
) )
} }
console.log('App Stream Config loaded!') process.logger.info('App Stream Config loaded!')
} }
export const addEntryToAppStreamConfig = ( export const addEntryToAppStreamConfig = (

View File

@@ -8,6 +8,6 @@ export const connectDB = async () => {
throw new Error('Unable to connect to DB!') throw new Error('Unable to connect to DB!')
} }
console.log('Connected to DB!') process.logger.success('Connected to DB!')
return seedDB() return seedDB()
} }

View File

@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
export const copySASjsCore = async () => { export const copySASjsCore = async () => {
if (process.env.NODE_ENV === 'test') return if (process.env.NODE_ENV === 'test') return
console.log('Copying Macros from container to drive.') process.logger.log('Copying Macros from container to drive.')
const macrosDrivePath = getMacrosFolder() const macrosDrivePath = getMacrosFolder()
@@ -30,5 +30,5 @@ export const copySASjsCore = async () => {
await createFile(macroFileDestPath, macroContent) await createFile(macroFileDestPath, macroContent)
}) })
console.log('Macros Drive Path:', macrosDrivePath) process.logger.info('Macros Drive Path:', macrosDrivePath)
} }

View 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)
}

4
api/src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,4 @@
import { randomBytes } from 'crypto'
export const randomBytesHexString = (bytesCount: number) =>
randomBytes(bytesCount).toString('hex')

View File

@@ -1,7 +1,8 @@
import { Request } from 'express' import { Request } from 'express'
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
const StaticAuthorizedRoutes = [ const StaticAuthorizedRoutes = [
'/AppStream',
'/SASjsApi/code/execute', '/SASjsApi/code/execute',
'/SASjsApi/stp/execute', '/SASjsApi/stp/execute',
'/SASjsApi/drive/deploy', '/SASjsApi/drive/deploy',
@@ -15,7 +16,7 @@ const StaticAuthorizedRoutes = [
export const getAuthorizedRoutes = () => { export const getAuthorizedRoutes = () => {
const streamingApps = Object.keys(process.appStreamConfig) const streamingApps = Object.keys(process.appStreamConfig)
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`) const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes] return [...TopLevelRoutes, ...StaticAuthorizedRoutes, ...streamingAppsRoutes]
} }
export const getPath = (req: Request) => { export const getPath = (req: Request) => {

View File

@@ -10,9 +10,9 @@ export const getCertificates = async () => {
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)')) const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
const caPath = CA_ROOT const caPath = CA_ROOT
console.log('keyPath: ', keyPath) process.logger.info('keyPath: ', keyPath)
console.log('certPath: ', certPath) process.logger.info('certPath: ', certPath)
if (caPath) console.log('caPath: ', caPath) if (caPath) process.logger.info('caPath: ', caPath)
const key = await readFile(keyPath) const key = await readFile(keyPath)
const cert = await readFile(certPath) const cert = await readFile(certPath)

View File

@@ -22,7 +22,7 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
//So this is workaround. //So this is workaround.
return { return {
username: user ? user.username : 'demo', username: user ? user.username : 'demo',
userId: user ? user.userId : 0, userId: user ? user.userId : 'demoId',
displayName: user ? user.displayName : 'demo', displayName: user ? user.displayName : 'demo',
serverUrl: protocol + host, serverUrl: protocol + host,
httpHeaders httpHeaders

View File

@@ -4,7 +4,7 @@ import User from '../model/User'
const isValidToken = async ( const isValidToken = async (
token: string, token: string,
key: string, key: string,
userId: number, userId: string,
clientId: string clientId: string
) => { ) => {
const promise = new Promise<boolean>((resolve, reject) => const promise = new Promise<boolean>((resolve, reject) =>
@@ -22,8 +22,8 @@ const isValidToken = async (
return await promise.then(() => true).catch(() => false) return await promise.then(() => true).catch(() => false)
} }
export const getTokensFromDB = async (userId: number, clientId: string) => { export const getTokensFromDB = async (userId: string, clientId: string) => {
const user = await User.findOne({ id: userId }) const user = await User.findOne({ _id: userId })
if (!user) return if (!user) return
const currentTokenObj = user.tokens.find( const currentTokenObj = user.tokens.find(

View File

@@ -1,6 +1,8 @@
export * from './appStreamConfig' export * from './appStreamConfig'
export * from './connectDB' export * from './connectDB'
export * from './copySASjsCore' export * from './copySASjsCore'
export * from './createWeboutSasFile'
export * from './crypto'
export * from './desktopAutoExec' export * from './desktopAutoExec'
export * from './extractHeaders' export * from './extractHeaders'
export * from './extractName' export * from './extractName'
@@ -19,8 +21,8 @@ export * from './instantiateLogger'
export * from './isDebugOn' export * from './isDebugOn'
export * from './isPublicRoute' export * from './isPublicRoute'
export * from './ldapClient' export * from './ldapClient'
export * from './zipped'
export * from './parseLogToArray' export * from './parseLogToArray'
export * from './rateLimiter'
export * from './removeTokensInDB' export * from './removeTokensInDB'
export * from './saveTokensInDB' export * from './saveTokensInDB'
export * from './seedDB' export * from './seedDB'
@@ -31,3 +33,4 @@ export * from './upload'
export * from './validation' export * from './validation'
export * from './verifyEnvVariables' export * from './verifyEnvVariables'
export * from './verifyTokenInDB' export * from './verifyTokenInDB'
export * from './zipped'

View File

@@ -22,10 +22,11 @@ export const isPublicRoute = async (req: Request): Promise<boolean> => {
} }
export const publicUser: RequestUser = { export const publicUser: RequestUser = {
userId: 0, userId: 'public_user_id',
clientId: 'public_app', clientId: 'public_app',
username: 'publicUser', username: 'publicUser',
displayName: 'Public User', displayName: 'Public User',
isAdmin: false, isAdmin: false,
isActive: true isActive: true,
needsToUpdatePassword: false
} }

View File

@@ -22,12 +22,12 @@ export const getEnvCSPDirectives = (
try { try {
cspConfigJson = JSON.parse(file) cspConfigJson = JSON.parse(file)
} catch (e) { } catch (e) {
console.error( process.logger.error(
'Parsing Content Security Policy JSON config failed. Make sure it is valid json' 'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
) )
} }
} catch (e) { } catch (e) {
console.error('Error reading HELMET CSP config file', e) process.logger.error('Error reading HELMET CSP config file', e)
} }
} }

View File

@@ -0,0 +1,123 @@
import { RateLimiterMemory } from 'rate-limiter-flexible'
export class RateLimiter {
private static instance: RateLimiter
private limiterSlowBruteByIP: RateLimiterMemory
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
private maxWrongAttemptsByIpPerDay: number
private maxConsecutiveFailsByUsernameAndIp: number
private constructor() {
const {
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
this.maxWrongAttemptsByIpPerDay = Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY)
this.maxConsecutiveFailsByUsernameAndIp = Number(
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
)
this.limiterSlowBruteByIP = new RateLimiterMemory({
keyPrefix: 'login_fail_ip_per_day',
points: this.maxWrongAttemptsByIpPerDay,
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24 // Block for 1 day
})
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: this.maxConsecutiveFailsByUsernameAndIp,
duration: 60 * 60 * 24 * 24, // Store number for 24 days since first fail
blockDuration: 60 * 60 // Block for 1 hour
})
}
public static getInstance() {
if (!RateLimiter.instance) {
RateLimiter.instance = new RateLimiter()
}
return RateLimiter.instance
}
private getUsernameIPKey(ip: string, username: string) {
return `${username}_${ip}`
}
/**
* This method checks for brute force attack
* If attack is detected then returns the number of seconds after which user can make another request
* Else returns 0
*/
public async check(ip: string, username: string) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
const [resSlowByIP, resUsernameAndIP] = await Promise.all([
this.limiterSlowBruteByIP.get(ip),
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
])
// NOTE: To make use of blockDuration option, comparison in both following if statements should have greater than symbol
// otherwise, blockDuration option will not work
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
// Check if IP or Username + IP is already blocked
if (
resSlowByIP !== null &&
resSlowByIP.consumedPoints > this.maxWrongAttemptsByIpPerDay
) {
return Math.ceil(resSlowByIP.msBeforeNext / 1000)
} else if (
resUsernameAndIP !== null &&
resUsernameAndIP.consumedPoints > this.maxConsecutiveFailsByUsernameAndIp
) {
return Math.ceil(resUsernameAndIP.msBeforeNext / 1000)
}
return 0
}
/**
* Consume 1 point from limiters on wrong attempt and block if limits reached
* If limit is reached, return the number of seconds after which user can make another request
* Else return 0
*/
public async consume(ip: string, username?: string) {
try {
const promises = [this.limiterSlowBruteByIP.consume(ip)]
if (username) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
// Count failed attempts by Username + IP only for registered users
promises.push(
this.limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey)
)
}
await Promise.all(promises)
} catch (rlRejected: any) {
if (rlRejected instanceof Error) {
throw rlRejected
} else {
// based upon the implementation of consume method of RateLimiterMemory
// we are sure that rlRejected will contain msBeforeNext
// for further reference,
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
// or see https://github.com/animir/node-rate-limiter-flexible#ratelimiterres-object
return Math.ceil(rlRejected.msBeforeNext / 1000)
}
}
return 0
}
public async resetOnSuccess(ip: string, username: string) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
const resUsernameAndIP =
await this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
await this.limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey)
}
}
}

View File

@@ -1,7 +1,7 @@
import User from '../model/User' import User from '../model/User'
export const removeTokensInDB = async (userId: number, clientId: string) => { export const removeTokensInDB = async (userId: string, clientId: string) => {
const user = await User.findOne({ id: userId }) const user = await User.findOne({ _id: userId })
if (!user) return if (!user) return
const tokenObjIndex = user.tokens.findIndex( const tokenObjIndex = user.tokens.findIndex(

View File

@@ -1,12 +1,12 @@
import User from '../model/User' import User from '../model/User'
export const saveTokensInDB = async ( export const saveTokensInDB = async (
userId: number, userId: string,
clientId: string, clientId: string,
accessToken: string, accessToken: string,
refreshToken: string refreshToken: string
) => { ) => {
const user = await User.findOne({ id: userId }) const user = await User.findOne({ _id: userId })
if (!user) return if (!user) return
const currentTokenObj = user.tokens.find( const currentTokenObj = user.tokens.find(

View File

@@ -1,7 +1,9 @@
import bcrypt from 'bcryptjs'
import Client from '../model/Client' import Client from '../model/Client'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group' import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User' import User, { IUser } from '../model/User'
import Configuration, { ConfigurationType } from '../model/Configuration' import Configuration, { ConfigurationType } from '../model/Configuration'
import { ResetAdminPasswordType } from './verifyEnvVariables'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
@@ -19,16 +21,16 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const client = new Client(CLIENT) const client = new Client(CLIENT)
await client.save() await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`) process.logger.success(`DB Seed - client created: ${CLIENT.clientId}`)
} }
// Checking if 'AllUsers' Group is already in the database // Checking if 'AllUsers' Group is already in the database
let groupExist = await Group.findOne({ name: GROUP.name }) let groupExist = await Group.findOne({ name: ALL_USERS_GROUP.name })
if (!groupExist) { if (!groupExist) {
const group = new Group(GROUP) const group = new Group(ALL_USERS_GROUP)
groupExist = await group.save() groupExist = await group.save()
console.log(`DB Seed - Group created: ${GROUP.name}`) process.logger.success(`DB Seed - Group created: ${ALL_USERS_GROUP.name}`)
} }
// Checking if 'Public' Group is already in the database // Checking if 'Public' Group is already in the database
@@ -37,22 +39,28 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const group = new Group(PUBLIC_GROUP) const group = new Group(PUBLIC_GROUP)
await group.save() await group.save()
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`) process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
} }
const ADMIN_USER = getAdminUser()
// Checking if user is already in the database // Checking if user is already in the database
let usernameExist = await User.findOne({ username: ADMIN_USER.username }) let usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) { if (usernameExist) {
usernameExist = await resetAdminPassword(usernameExist, ADMIN_USER.password)
} else {
const user = new User(ADMIN_USER) const user = new User(ADMIN_USER)
usernameExist = await user.save() usernameExist = await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`) process.logger.success(
`DB Seed - admin account created: ${ADMIN_USER.username}`
)
} }
if (!groupExist.hasUser(usernameExist)) { if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
groupExist.addUser(usernameExist) groupExist.addUser(usernameExist)
console.log( process.logger.success(
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'` `DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
) )
} }
@@ -62,7 +70,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const configuration = new Configuration(SECRETS) const configuration = new Configuration(SECRETS)
configExist = await configuration.save() configExist = await configuration.save()
console.log('DB Seed - configuration added') process.logger.success('DB Seed - configuration added')
} }
return { return {
@@ -73,8 +81,8 @@ export const seedDB = async (): Promise<ConfigurationType> => {
} }
} }
const GROUP = { export const ALL_USERS_GROUP = {
name: 'AllUsers', name: 'all-users',
description: 'Group contains all users' description: 'Group contains all users'
} }
@@ -88,11 +96,52 @@ const CLIENT = {
clientId: 'clientID1', clientId: 'clientID1',
clientSecret: 'clientSecret' clientSecret: 'clientSecret'
} }
const ADMIN_USER = {
id: 1, const getAdminUser = () => {
displayName: 'Super Admin', const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
username: 'secretuser',
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO', const salt = bcrypt.genSaltSync(10)
isAdmin: true, const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
isActive: true
return {
displayName: 'Super Admin',
username: ADMIN_USERNAME,
password: hashedPassword,
isAdmin: true,
isActive: true
}
}
const resetAdminPassword = async (user: IUser, password: string) => {
const { ADMIN_PASSWORD_RESET } = process.env
if (ADMIN_PASSWORD_RESET === ResetAdminPasswordType.YES) {
if (!user.isAdmin) {
process.logger.error(
`Can not reset the password of non-admin user (${user.username}) on startup.`
)
return user
}
if (user.authProvider) {
process.logger.error(
`Can not reset the password of admin (${user.username}) with ${user.authProvider} as authentication mechanism.`
)
return user
}
process.logger.info(
`DB Seed - resetting password for admin user: ${user.username}`
)
user.password = password
user.needsToUpdatePassword = true
user = await user.save()
process.logger.success(`DB Seed - successfully reset the password`)
}
return user
} }

View File

@@ -1,9 +1,31 @@
import path from 'path' import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils' import {
createFolder,
getAbsolutePath,
getRealPath,
fileExists
} from '@sasjs/utils'
import dotenv from 'dotenv'
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.' import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
export const setProcessVariables = async () => { export const setProcessVariables = async () => {
const { execPath } = process
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
// This is needed to fix picking .env file issue in MacOS executable.
if (execPath) {
const envPathSplitted = execPath.split(path.sep)
if (envPathSplitted.pop() === 'api-macos') {
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
// Override environment variables from envPath if file exists
if (await fileExists(envPath)) {
dotenv.config({ path: envPath, override: true })
}
}
}
const { MODE, RUN_TIMES } = process.env const { MODE, RUN_TIMES } = process.env
if (MODE === ModeType.Server) { if (MODE === ModeType.Server) {
@@ -21,6 +43,7 @@ export const setProcessVariables = async () => {
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root') process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive') process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
return return
} }
@@ -41,7 +64,9 @@ export const setProcessVariables = async () => {
const { SASJS_ROOT } = process.env const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd()) const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath) await createFolder(absPath)
process.sasjsRoot = getRealPath(absPath) process.sasjsRoot = getRealPath(absPath)
const { DRIVE_LOCATION } = process.env const { DRIVE_LOCATION } = process.env
@@ -49,6 +74,7 @@ export const setProcessVariables = async () => {
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'), DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
process.cwd() process.cwd()
) )
await createFolder(absDrivePath) await createFolder(absDrivePath)
process.driveLoc = getRealPath(absDrivePath) process.driveLoc = getRealPath(absDrivePath)
@@ -57,13 +83,15 @@ export const setProcessVariables = async () => {
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'), LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
process.cwd() process.cwd()
) )
await createFolder(absLogsPath) await createFolder(absLogsPath)
process.logsLoc = getRealPath(absLogsPath) process.logsLoc = getRealPath(absLogsPath)
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784' process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
console.log('sasLoc: ', process.sasLoc) process.logger.info('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc) process.logger.info('sasDrive: ', process.driveLoc)
console.log('sasLogs: ', process.logsLoc) process.logger.info('sasLogs: ', process.logsLoc)
console.log('runTimes: ', process.runTimes) process.logger.info('runTimes: ', process.runTimes)
} }

View File

@@ -1,7 +1,7 @@
import { createFolder } from '@sasjs/utils' import { createFolder } from '@sasjs/utils'
import { getFilesFolder, getPackagesFolder } from './file' import { getFilesFolder, getPackagesFolder } from './file'
export const setupFolders = async () => { export const setupFilesFolder = async () => await createFolder(getFilesFolder())
await createFolder(getFilesFolder())
export const setupPackagesFolder = async () =>
await createFolder(getPackagesFolder()) await createFolder(getPackagesFolder())
}

View File

@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
let fileCount = 0 let fileCount = 0
const uploadedFiles: UploadedFiles[] = [] const uploadedFiles: UploadedFiles[] = []
const sasSessionFolderList: string[] = await listFilesInFolder( const sasSessionFolderList: string[] =
sasSessionFolder await listFilesInFolder(sasSessionFolder)
)
sasSessionFolderList.forEach((fileName) => { sasSessionFolderList.forEach((fileName) => {
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount fileCountString = fileCount < 10 ? '00' + fileCount : fileCount

View File

@@ -12,6 +12,11 @@ const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i export const blockFileRegex = /\.(exe|sh|htaccess)$/i
export const uidValidation = (data: any) =>
Joi.object({
uid: Joi.string().length(24).hex().required()
}).validate(data)
export const getUserValidation = (data: any): Joi.ValidationResult => export const getUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
username: usernameSchema.required() username: usernameSchema.required()
@@ -85,6 +90,12 @@ export const updateUserValidation = (
return Joi.object(validationChecks).validate(data) return Joi.object(validationChecks).validate(data)
} }
export const updatePasswordValidation = (data: any): Joi.ValidationResult =>
Joi.object({
currentPassword: Joi.string().required(),
newPassword: passwordSchema.required()
}).validate(data)
export const registerClientValidation = (data: any): Joi.ValidationResult => export const registerClientValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
clientId: Joi.string().required(), clientId: Joi.string().required(),
@@ -107,7 +118,7 @@ export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
principalType: Joi.string() principalType: Joi.string()
.required() .required()
.valid(...Object.values(PrincipalType)), .valid(...Object.values(PrincipalType)),
principalId: Joi.number().required() principalId: Joi.string().length(24).hex().required()
}).validate(data) }).validate(data)
export const updatePermissionValidation = (data: any): Joi.ValidationResult => export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
@@ -172,9 +183,17 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
runTime: Joi.string().valid(...process.runTimes) runTime: Joi.string().valid(...process.runTimes)
}).validate(data) }).validate(data)
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
code: Joi.string().required(),
runTime: Joi.string().valid(...process.runTimes),
expiresAfterMins: Joi.number().greater(0)
}).validate(data)
export const executeProgramRawValidation = (data: any): Joi.ValidationResult => export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
_program: Joi.string().required() _program: Joi.string().required(),
_debug: Joi.number()
}) })
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number())) .pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data) .validate(data)

View File

@@ -47,6 +47,16 @@ export enum ReturnCode {
InvalidEnv InvalidEnv
} }
export enum DatabaseType {
MONGO = 'mongodb',
COSMOS_MONGODB = 'cosmos_mongodb'
}
export enum ResetAdminPasswordType {
YES = 'YES',
NO = 'NO'
}
export const verifyEnvVariables = (): ReturnCode => { export const verifyEnvVariables = (): ReturnCode => {
const errors: string[] = [] const errors: string[] = []
@@ -70,6 +80,12 @@ export const verifyEnvVariables = (): ReturnCode => {
errors.push(...verifyLDAPVariables()) errors.push(...verifyLDAPVariables())
errors.push(...verifyDbType())
errors.push(...verifyRateLimiter())
errors.push(...verifyAdminUserConfig())
if (errors.length) { if (errors.length) {
process.logger?.error( process.logger?.error(
`Invalid environment variable(s) provided: \n${errors.join('\n')}` `Invalid environment variable(s) provided: \n${errors.join('\n')}`
@@ -342,11 +358,111 @@ const verifyLDAPVariables = () => {
return errors return errors
} }
const verifyDbType = () => {
const errors: string[] = []
const { MODE, DB_TYPE } = process.env
if (MODE === ModeType.Server) {
if (DB_TYPE) {
const dbTypes = Object.values(DatabaseType)
if (!dbTypes.includes(DB_TYPE as DatabaseType))
errors.push(`- DB_TYPE '${DB_TYPE}'\n - valid options ${dbTypes}`)
} else {
process.env.DB_TYPE = DEFAULTS.DB_TYPE
}
}
return errors
}
const verifyRateLimiter = () => {
const errors: string[] = []
const {
MODE,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
if (MODE === ModeType.Server) {
if (MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) {
if (
!isNumeric(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) ||
Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) < 1
) {
errors.push(
`- Invalid value for 'MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY =
DEFAULTS.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
}
if (MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) {
if (
!isNumeric(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) ||
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) < 1
) {
errors.push(
`- Invalid value for 'MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP =
DEFAULTS.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
}
}
return errors
}
const verifyAdminUserConfig = () => {
const errors: string[] = []
const { MODE, ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL, ADMIN_PASSWORD_RESET } =
process.env
if (MODE === ModeType.Server) {
if (ADMIN_USERNAME) {
process.env.ADMIN_USERNAME = ADMIN_USERNAME.toLowerCase()
} else {
process.env.ADMIN_USERNAME = DEFAULTS.ADMIN_USERNAME
}
if (!ADMIN_PASSWORD_INITIAL)
process.env.ADMIN_PASSWORD_INITIAL = DEFAULTS.ADMIN_PASSWORD_INITIAL
if (ADMIN_PASSWORD_RESET) {
const resetPasswordTypes = Object.values(ResetAdminPasswordType)
if (
!resetPasswordTypes.includes(
ADMIN_PASSWORD_RESET as ResetAdminPasswordType
)
)
errors.push(
`- ADMIN_PASSWORD_RESET '${ADMIN_PASSWORD_RESET}'\n - valid options ${resetPasswordTypes}`
)
} else {
process.env.ADMIN_PASSWORD_RESET = DEFAULTS.ADMIN_PASSWORD_RESET
}
}
return errors
}
const isNumeric = (val: string): boolean => {
return !isNaN(Number(val))
}
const DEFAULTS = { const DEFAULTS = {
MODE: ModeType.Desktop, MODE: ModeType.Desktop,
PROTOCOL: ProtocolType.HTTP, PROTOCOL: ProtocolType.HTTP,
PORT: '5000', PORT: '5000',
HELMET_COEP: HelmetCoepType.TRUE, HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common, LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
RUN_TIMES: RunTimeType.SAS RUN_TIMES: RunTimeType.SAS,
DB_TYPE: DatabaseType.MONGO,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10',
ADMIN_USERNAME: 'secretuser',
ADMIN_PASSWORD_INITIAL: 'secretpassword',
ADMIN_PASSWORD_RESET: ResetAdminPasswordType.NO
} }

View File

@@ -4,7 +4,7 @@ import { RequestUser } from '../types'
export const fetchLatestAutoExec = async ( export const fetchLatestAutoExec = async (
reqUser: RequestUser reqUser: RequestUser
): Promise<RequestUser | undefined> => { ): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: reqUser.userId }) const dbUser = await User.findOne({ _id: reqUser.userId })
if (!dbUser) return undefined if (!dbUser) return undefined
@@ -15,17 +15,18 @@ export const fetchLatestAutoExec = async (
displayName: dbUser.displayName, displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive, isActive: dbUser.isActive,
needsToUpdatePassword: dbUser.needsToUpdatePassword,
autoExec: dbUser.autoExec autoExec: dbUser.autoExec
} }
} }
export const verifyTokenInDB = async ( export const verifyTokenInDB = async (
userId: number, userId: string,
clientId: string, clientId: string,
token: string, token: string,
tokenType: 'accessToken' | 'refreshToken' tokenType: 'accessToken' | 'refreshToken'
): Promise<RequestUser | undefined> => { ): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: userId }) const dbUser = await User.findOne({ _id: userId })
if (!dbUser) return undefined if (!dbUser) return undefined
@@ -41,6 +42,7 @@ export const verifyTokenInDB = async (
displayName: dbUser.displayName, displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive, isActive: dbUser.isActive,
needsToUpdatePassword: dbUser.needsToUpdatePassword,
autoExec: dbUser.autoExec autoExec: dbUser.autoExec
} }
: undefined : undefined

1617
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-highlight": "^0.15.0",
"react-monaco-editor": "^0.48.0", "react-monaco-editor": "^0.48.0",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-toastify": "^9.0.1" "react-toastify": "^9.0.1"
@@ -41,6 +42,7 @@
"@types/react": "^17.0.37", "@types/react": "^17.0.37",
"@types/react-copy-to-clipboard": "^5.0.2", "@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"@types/react-highlight": "^0.12.5",
"@types/react-router-dom": "^5.3.1", "@types/react-router-dom": "^5.3.1",
"babel-loader": "^8.2.3", "babel-loader": "^8.2.3",
"babel-plugin-prismjs": "^2.1.0", "babel-plugin-prismjs": "^2.1.0",
@@ -59,6 +61,7 @@
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"ts-loader": "^9.2.6", "ts-loader": "^9.2.6",
"typescript": "^4.5.2", "typescript": "^4.5.2",
"typescript-plugin-css-modules": "^5.0.1",
"webpack": "5.64.3", "webpack": "5.64.3",
"webpack-cli": "^4.9.2", "webpack-cli": "^4.9.2",
"webpack-dev-server": "4.7.4" "webpack-dev-server": "4.7.4"

View File

@@ -8,6 +8,7 @@ import Header from './components/header'
import Home from './components/home' import Home from './components/home'
import Studio from './containers/Studio' import Studio from './containers/Studio'
import Settings from './containers/Settings' import Settings from './containers/Settings'
import UpdatePassword from './components/updatePassword'
import { AppContext } from './context/appContext' import { AppContext } from './context/appContext'
import AuthCode from './containers/AuthCode' import AuthCode from './containers/AuthCode'
@@ -29,6 +30,20 @@ function App() {
) )
} }
if (appContext.needsToUpdatePassword) {
return (
<ThemeProvider theme={theme}>
<HashRouter>
<Header />
<Routes>
<Route path="*" element={<UpdatePassword />} />
</Routes>
<ToastContainer />
</HashRouter>
</ThemeProvider>
)
}
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<HashRouter> <HashRouter>

View File

@@ -5,7 +5,7 @@ import Box from '@mui/material/Box'
const Home = () => { const Home = () => {
return ( return (
<Box className="main"> <Box className="container">
<CssBaseline /> <CssBaseline />
<h2>Welcome to SASjs Server!</h2> <h2>Welcome to SASjs Server!</h2>
<p> <p>

View File

@@ -2,7 +2,14 @@ import axios from 'axios'
import React, { useState, useContext } from 'react' import React, { useState, useContext } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { CssBaseline, Box, TextField, Button } from '@mui/material' import {
Backdrop,
CircularProgress,
CssBaseline,
Box,
TextField,
Button
} from '@mui/material'
import { AppContext } from '../context/appContext' import { AppContext } from '../context/appContext'
const login = async (payload: { username: string; password: string }) => const login = async (payload: { username: string; password: string }) =>
@@ -10,21 +17,27 @@ const login = async (payload: { username: string; password: string }) =>
const Login = () => { const Login = () => {
const appContext = useContext(AppContext) const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const handleSubmit = async (e: any) => { const handleSubmit = async (e: any) => {
setIsLoading(true)
setErrorMessage('') setErrorMessage('')
e.preventDefault() e.preventDefault()
const { loggedIn, user } = await login({ const { loggedIn, user } = await login({
username, username,
password password
}).catch((err: any) => {
setErrorMessage(err.response?.data || err.toString())
return {}
}) })
.catch((err: any) => {
setErrorMessage(err.response?.data || err.toString())
return {}
})
.finally(() => {
setIsLoading(false)
})
if (loggedIn) { if (loggedIn) {
appContext.setUserId?.(user.id) appContext.setUserId?.(user.id)
@@ -32,46 +45,56 @@ const Login = () => {
appContext.setDisplayName?.(user.displayName) appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin) appContext.setIsAdmin?.(user.isAdmin)
appContext.setLoggedIn?.(loggedIn) appContext.setLoggedIn?.(loggedIn)
appContext.setNeedsToUpdatePassword?.(user.needsToUpdatePassword)
} }
} }
return ( return (
<Box <>
className="main" <Backdrop
component="form" sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
onSubmit={handleSubmit} open={isLoading}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
<TextField
id="username"
label="Username"
type="text"
variant="outlined"
onChange={(e: any) => setUsername(e.target.value)}
required
/>
<TextField
id="password"
label="Password"
type="password"
variant="outlined"
onChange={(e: any) => setPassword(e.target.value)}
required
/>
{errorMessage && <span>{errorMessage}</span>}
<Button
type="submit"
variant="outlined"
disabled={!appContext.setLoggedIn}
> >
Submit <CircularProgress color="inherit" />
</Button> </Backdrop>
</Box>
<Box
className="container"
component="form"
onSubmit={handleSubmit}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
<TextField
id="username"
label="Username"
type="text"
variant="outlined"
onChange={(e: any) => setUsername(e.target.value)}
required
/>
<TextField
id="password"
label="Password"
type="password"
variant="outlined"
onChange={(e: any) => setPassword(e.target.value)}
required
/>
{errorMessage && <span>{errorMessage}</span>}
<Button
type="submit"
variant="outlined"
disabled={!appContext.setLoggedIn}
>
Submit
</Button>
</Box>
</>
) )
} }

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react'
import {
Grid,
DialogContent,
DialogActions,
Button,
OutlinedInput,
InputAdornment,
IconButton,
FormControl,
InputLabel,
FormHelperText
} from '@mui/material'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { BootstrapDialogTitle } from './dialogTitle'
import { BootstrapDialog } from './modal'
type Props = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
title: string
updatePassword: (currentPassword: string, newPassword: string) => void
}
const UpdatePasswordModal = (props: Props) => {
const { open, setOpen, title, updatePassword } = props
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => {
if (
currentPassword.length > 0 &&
newPassword.length > 0 &&
newPassword === currentPassword
) {
setErrorText('New password should be different to current password.')
setHasError(true)
} else if (newPassword.length >= 6) {
setErrorText('')
setHasError(false)
}
}, [currentPassword, newPassword])
const handleBlur = () => {
if (newPassword.length < 6) {
setErrorText('Password length should be at least 6 characters.')
setHasError(true)
}
}
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid item xs={12}>
<PasswordInput
label="Current Password"
password={currentPassword}
setPassword={setCurrentPassword}
/>
</Grid>
<Grid item xs={12}>
<PasswordInput
label="New Password"
password={newPassword}
setPassword={setNewPassword}
hasError={hasError}
errorText={errorText}
handleBlur={handleBlur}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => updatePassword(currentPassword, newPassword)}
disabled={hasError || !currentPassword || !newPassword}
>
Update
</Button>
</DialogActions>
</BootstrapDialog>
</div>
)
}
export default UpdatePasswordModal
type PasswordInputProps = {
label: string
password: string
setPassword: React.Dispatch<React.SetStateAction<string>>
hasError?: boolean
errorText?: string
handleBlur?: () => void
}
export const PasswordInput = ({
label,
password,
setPassword,
hasError,
errorText,
handleBlur
}: PasswordInputProps) => {
const [showPassword, setShowPassword] = useState(false)
return (
<FormControl sx={{ width: '100%' }} variant="outlined" error={hasError}>
<InputLabel htmlFor="outlined-adornment-password">{label}</InputLabel>
<OutlinedInput
id="outlined-adornment-password"
type={showPassword ? 'text' : 'password'}
label={label}
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={handleBlur}
endAdornment={
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword((val) => !val)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
/>
{errorText && <FormHelperText>{errorText}</FormHelperText>}
</FormControl>
)
}

View File

@@ -3,12 +3,11 @@ import Snackbar from '@mui/material/Snackbar'
import MuiAlert, { AlertProps } from '@mui/material/Alert' import MuiAlert, { AlertProps } from '@mui/material/Alert'
import Slide, { SlideProps } from '@mui/material/Slide' import Slide, { SlideProps } from '@mui/material/Slide'
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert( const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
props, function Alert(props, ref) {
ref return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
) { }
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} /> )
})
const Transition = (props: SlideProps) => { const Transition = (props: SlideProps) => {
return <Slide {...props} direction="up" /> return <Slide {...props} direction="up" />

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect, useContext } from 'react'
import axios from 'axios'
import { Box, CssBaseline, Button, CircularProgress } from '@mui/material'
import { toast } from 'react-toastify'
import { PasswordInput } from './passwordModal'
import { AppContext } from '../context/appContext'
const UpdatePassword = () => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => {
if (
currentPassword.length > 0 &&
newPassword.length > 0 &&
newPassword === currentPassword
) {
setErrorText('New password should be different to current password.')
setHasError(true)
} else if (newPassword.length >= 6) {
setErrorText('')
setHasError(false)
}
}, [currentPassword, newPassword])
const handleBlur = () => {
if (newPassword.length < 6) {
setErrorText('Password length should be at least 6 characters.')
setHasError(true)
}
}
const handleSubmit = async (e: any) => {
e.preventDefault()
if (hasError || !currentPassword || !newPassword) return
setIsLoading(true)
axios
.patch(`/SASjsApi/auth/updatePassword`, {
currentPassword,
newPassword
})
.then((res: any) => {
appContext.setNeedsToUpdatePassword?.(false)
toast.success('Password updated', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.catch((err) => {
toast.error('Failed: ' + err.response?.data || err.text, {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.finally(() => {
setIsLoading(false)
})
}
return isLoading ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Box
className="container"
component="form"
onSubmit={handleSubmit}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<h2>Welcome to SASjs Server!</h2>
<p style={{ width: 'auto' }}>
This is your first time login to SASjs server. Therefore, you need to
update your password.
</p>
<PasswordInput
label="Current Password"
password={currentPassword}
setPassword={setCurrentPassword}
/>
<PasswordInput
label="New Password"
password={newPassword}
setPassword={setNewPassword}
hasError={hasError}
errorText={errorText}
handleBlur={handleBlur}
/>
<Button
type="submit"
variant="outlined"
disabled={hasError || !currentPassword || !newPassword}
>
Update
</Button>
</Box>
)
}
export default UpdatePassword

View File

@@ -47,7 +47,7 @@ const AuthCode = () => {
} }
return ( return (
<Box className="main"> <Box className="container">
<CssBaseline /> <CssBaseline />
<br /> <br />
<h2>Authorization Code</h2> <h2>Authorization Code</h2>

View File

@@ -99,8 +99,8 @@ const AddPermissionModal = ({
principalType: principalType.toLowerCase(), principalType: principalType.toLowerCase(),
principalId: principalId:
principalType.toLowerCase() === 'user' principalType.toLowerCase() === 'user'
? userPrincipal?.id ? userPrincipal?.uid
: groupPrincipal?.groupId : groupPrincipal?.uid
} }
permissions.push(addPermissionPayload) permissions.push(addPermissionPayload)

View File

@@ -61,7 +61,7 @@ const PermissionTable = ({
</TableHead> </TableHead>
<TableBody> <TableBody>
{permissions.map((permission) => ( {permissions.map((permission) => (
<TableRow key={permission.permissionId}> <TableRow key={permission.uid}>
<BootstrapTableCell>{permission.path}</BootstrapTableCell> <BootstrapTableCell>{permission.path}</BootstrapTableCell>
<BootstrapTableCell>{permission.type}</BootstrapTableCell> <BootstrapTableCell>{permission.type}</BootstrapTableCell>
<BootstrapTableCell> <BootstrapTableCell>

View File

@@ -9,7 +9,7 @@ import { PermissionsContext } from '../../../../context/permissionsContext'
import { import {
findExistingPermission, findExistingPermission,
findUpdatingPermission findUpdatingPermission
} from '../../../../utils/helper' } from '../../../../utils'
const useAddPermission = () => { const useAddPermission = () => {
const { const {
@@ -69,7 +69,7 @@ const useAddPermission = () => {
for (const permission of updatingPermissions) { for (const permission of updatingPermissions) {
await axios await axios
.patch(`/SASjsApi/permission/${permission.permissionId}`, { .patch(`/SASjsApi/permission/${permission.uid}`, {
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant' setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
}) })
.then((res) => { .then((res) => {

View File

@@ -24,7 +24,7 @@ const useDeletePermissionModal = () => {
setDeleteConfirmationModalOpen(false) setDeleteConfirmationModalOpen(false)
setIsLoading(true) setIsLoading(true)
axios axios
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`) .delete(`/SASjsApi/permission/${selectedPermission?.uid}`)
.then((res: any) => { .then((res: any) => {
fetchPermissions() fetchPermissions()
setSnackbarMessage('Permission deleted!') setSnackbarMessage('Permission deleted!')

View File

@@ -62,21 +62,17 @@ const useFilterPermissions = () => {
: permissions : permissions
let filteredArray = uriFilteredPermissions.filter((permission) => let filteredArray = uriFilteredPermissions.filter((permission) =>
principalFilteredPermissions.some( principalFilteredPermissions.some((item) => item.uid === permission.uid)
(item) => item.permissionId === permission.permissionId
)
) )
filteredArray = filteredArray.filter((permission) => filteredArray = filteredArray.filter((permission) =>
principalTypeFilteredPermissions.some( principalTypeFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId (item) => item.uid === permission.uid
) )
) )
filteredArray = filteredArray.filter((permission) => filteredArray = filteredArray.filter((permission) =>
settingFilteredPermissions.some( settingFilteredPermissions.some((item) => item.uid === permission.uid)
(item) => item.permissionId === permission.permissionId
)
) )
setFilteredPermissions(filteredArray) setFilteredPermissions(filteredArray)

View File

@@ -24,7 +24,7 @@ const useUpdatePermissionModal = () => {
setUpdatePermissionModalOpen(false) setUpdatePermissionModalOpen(false)
setIsLoading(true) setIsLoading(true)
axios axios
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, { .patch(`/SASjsApi/permission/${selectedPermission?.uid}`, {
setting setting
}) })
.then((res: any) => { .then((res: any) => {

View File

@@ -17,26 +17,30 @@ import {
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { AppContext, ModeType } from '../../context/appContext' import { AppContext, ModeType } from '../../context/appContext'
import UpdatePasswordModal from '../../components/passwordModal'
const Profile = () => { const Profile = () => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const appContext = useContext(AppContext) const appContext = useContext(AppContext)
const [user, setUser] = useState({} as any) const [user, setUser] = useState({} as any)
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
setIsLoading(true) if (appContext.userId) {
axios setIsLoading(true)
.get(`/SASjsApi/user/${appContext.userId}`) axios
.then((res: any) => { .get(`/SASjsApi/user/${appContext.userId}`)
setUser(res.data) .then((res: any) => {
}) setUser(res.data)
.catch((err) => { })
console.log(err) .catch((err) => {
}) console.log(err)
.finally(() => { })
setIsLoading(false) .finally(() => {
}) setIsLoading(false)
}, []) })
}
}, [appContext.userId])
const handleChange = (event: any) => { const handleChange = (event: any) => {
const { name, value } = event.target const { name, value } = event.target
@@ -68,82 +72,124 @@ const Profile = () => {
}) })
} }
const updatePassword = (currentPassword: string, newPassword: string) => {
setIsLoading(true)
setIsPasswordModalOpen(false)
axios
.patch(`/SASjsApi/auth/updatePassword`, {
currentPassword,
newPassword
})
.then((res: any) => {
toast.success('Password updated', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.catch((err) => {
toast.error('Failed: ' + err.response?.data || err.text, {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.finally(() => {
setIsLoading(false)
})
}
return isLoading ? ( return isLoading ? (
<CircularProgress <CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }} style={{ position: 'absolute', left: '50%', top: '50%' }}
/> />
) : ( ) : (
<Card> <>
<CardHeader title="Profile Information" /> <Card>
<Divider /> <CardHeader title="Profile Information" />
<CardContent> <Divider />
<Grid container spacing={4}> <CardContent>
<Grid item md={6} xs={12}> <Grid container spacing={4}>
<TextField <Grid item md={6} xs={12}>
fullWidth <TextField
error={user.displayName?.length === 0} fullWidth
helperText="Please specify display name" error={user.displayName?.length === 0}
label="Display Name" helperText="Please specify display name"
name="displayName" label="Display Name"
onChange={handleChange} name="displayName"
required onChange={handleChange}
value={user.displayName} required
variant="outlined" value={user.displayName}
disabled={appContext.mode === ModeType.Desktop} variant="outlined"
/> disabled={appContext.mode === ModeType.Desktop}
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.username?.length === 0}
helperText="Please specify username"
label="Username"
name="username"
onChange={handleChange}
required
value={user.username}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</Grid>
<Grid item lg={6} md={8} sm={12} xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="10"
value={user.autoExec}
variant="outlined"
/>
</Grid>
<Grid item xs={6}>
<FormGroup row>
<FormControlLabel
disabled
control={<Checkbox checked={user.isActive} />}
label="isActive"
/> />
<FormControlLabel </Grid>
disabled
control={<Checkbox checked={user.isAdmin} />} <Grid item md={6} xs={12}>
label="isAdmin" <TextField
fullWidth
error={user.username?.length === 0}
helperText="Please specify username"
label="Username"
name="username"
onChange={handleChange}
required
value={user.username}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/> />
</FormGroup> </Grid>
<Grid item lg={6} md={8} sm={12} xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="10"
value={user.autoExec}
variant="outlined"
/>
</Grid>
<Grid item xs={6}>
<FormGroup row>
<FormControlLabel
disabled
control={<Checkbox checked={user.isActive} />}
label="isActive"
/>
<FormControlLabel
disabled
control={<Checkbox checked={user.isAdmin} />}
label="isAdmin"
/>
</FormGroup>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={() => setIsPasswordModalOpen(true)}
>
Update Password
</Button>
</Grid>
</Grid> </Grid>
</Grid> </CardContent>
</CardContent> <Divider />
<Divider /> <CardActions>
<CardActions> <Button type="submit" variant="contained" onClick={handleSubmit}>
<Button type="submit" variant="contained" onClick={handleSubmit}> Save Changes
Save Changes </Button>
</Button> </CardActions>
</CardActions> </Card>
</Card> <UpdatePasswordModal
open={isPasswordModalOpen}
setOpen={setIsPasswordModalOpen}
title="Update Password"
updatePassword={updatePassword}
/>
</>
) )
} }

View File

@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react'
import { import {
Backdrop, Backdrop,
@@ -17,10 +17,14 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'
import FilePathInputModal from '../../components/filePathInputModal' import FilePathInputModal from '../../components/filePathInputModal'
import FileMenu from './internal/components/fileMenu' import FileMenu from './internal/components/fileMenu'
import RunMenu from './internal/components/runMenu' import RunMenu from './internal/components/runMenu'
import LogComponent from './internal/components/log/logComponent'
import LogTabWithIcons from './internal/components/log/logTabWithIcons'
import { usePrompt } from '../../utils/hooks' import { usePrompt } from '../../utils/hooks'
import { getLanguageFromExtension } from './internal/helper' import { getLanguageFromExtension } from './internal/helper'
import useEditor from './internal/hooks/useEditor' import useEditor from './internal/hooks/useEditor'
import { RunTimeType } from '../../context/appContext'
import { LogObject } from '../../utils'
const StyledTabPanel = styled(TabPanel)(() => ({ const StyledTabPanel = styled(TabPanel)(() => ({
padding: '10px' padding: '10px'
@@ -58,6 +62,7 @@ const SASjsEditor = ({
selectedRunTime, selectedRunTime,
showDiff, showDiff,
webout, webout,
printOutput,
Dialog, Dialog,
handleChangeRunTime, handleChangeRunTime,
handleDiffEditorDidMount, handleDiffEditorDidMount,
@@ -108,6 +113,10 @@ const SASjsEditor = ({
/> />
) )
// INFO: variable indicating if selected run type is SAS if there are any errors or warnings in the log
const logWithErrorsOrWarnings =
selectedRunTime === RunTimeType.SAS && log && typeof log === 'object'
return ( return (
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}> <Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
<Backdrop <Backdrop
@@ -145,15 +154,35 @@ const SASjsEditor = ({
> >
<TabList onChange={handleTabChange} centered> <TabList onChange={handleTabChange} centered>
<StyledTab label="Code" value="code" /> <StyledTab label="Code" value="code" />
<StyledTab label="Log" value="log" /> {log && (
<StyledTab <StyledTab
label={ label={logWithErrorsOrWarnings ? '' : 'log'}
<Tooltip title="Displays content from the _webout fileref"> value="log"
<Typography>Webout</Typography> icon={
</Tooltip> logWithErrorsOrWarnings ? (
} <LogTabWithIcons log={log as LogObject} />
value="webout" ) : (
/> ''
)
}
onClick={() => {
const logWrapper = document.querySelector(`#logWrapper`)
if (logWrapper) logWrapper.scrollTop = 0
}}
/>
)}
{webout && (
<StyledTab
label={
<Tooltip title="Displays content from the _webout fileref">
<Typography>Webout</Typography>
</Tooltip>
}
value="webout"
/>
)}
{printOutput && <StyledTab label="print" value="printOutput" />}
</TabList> </TabList>
</Box> </Box>
@@ -195,18 +224,24 @@ const SASjsEditor = ({
</Paper> </Paper>
</StyledTabPanel> </StyledTabPanel>
<StyledTabPanel value="log"> <StyledTabPanel value="log">
<div> {log && (
<h2>Log</h2> <LogComponent log={log} selectedRunTime={selectedRunTime} />
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}> )}
{log}
</pre>
</div>
</StyledTabPanel>
<StyledTabPanel value="webout">
<div>
<pre>{webout}</pre>
</div>
</StyledTabPanel> </StyledTabPanel>
{webout && (
<StyledTabPanel value="webout">
<div>
<pre>{webout}</pre>
</div>
</StyledTabPanel>
)}
{printOutput && (
<StyledTabPanel value="printOutput">
<div>
<pre>{printOutput}</pre>
</div>
</StyledTabPanel>
)}
</TabContext> </TabContext>
)} )}
<Dialog /> <Dialog />

View File

@@ -0,0 +1,88 @@
.ChunkHeader {
color: #444;
cursor: pointer;
padding: 18px;
width: 100%;
text-align: left;
border: none;
outline: none;
transition: 0.4s;
box-shadow:
rgba(0, 0, 0, 0.2) 0px 2px 1px -1px,
rgba(0, 0, 0, 0.14) 0px 1px 1px 0px,
rgba(0, 0, 0, 0.12) 0px 1px 3px 0px;
}
.ChunkDetails {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.ChunkExpandIcon {
margin-left: auto;
}
.ChunkBody {
background-color: white;
overflow: hidden;
}
.ChunksContainer {
display: flex;
flex-direction: column;
gap: 10px;
}
.LogContainer {
background-color: #fbfbfb;
border: 1px solid #e2e2e2;
border-radius: 3px;
min-height: 50px;
padding: 10px;
box-sizing: border-box;
white-space: pre-wrap;
font-family: Monaco, Courier, monospace;
position: relative;
width: 100%;
}
.LogWrapper {
overflow-y: auto;
max-height: calc(100vh - 130px);
}
.LogBody {
overflow: auto;
height: calc(100vh - 220px);
}
.TreeContainer {
background-color: white;
padding-top: 10px;
padding-bottom: 10px;
}
.TabContainer {
display: flex;
flex-direction: row;
gap: 6px;
align-items: center;
}
.TabDownloadIcon {
margin-left: 20px;
}
.HighlightedLine {
background-color: #f6e30599;
}
.Icon {
font-size: 20px !important;
}
.GreenIcon {
color: green;
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, SyntheticEvent } from 'react'
import { Typography } from '@mui/material'
import Highlight from 'react-highlight'
import { ErrorOutline, Warning } from '@mui/icons-material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import CheckIcon from '@mui/icons-material/Check'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import {
defaultChunkSize,
parseErrorsAndWarnings,
LogInstance,
clearErrorsAndWarningsHtmlWrapping,
download
} from '../../../../../utils'
import { logStyles } from './logComponent'
import classes from './log.module.css'
interface LogChunkProps {
id: number
text: string
expanded: boolean
logLineCount: number
onClick: (evt: any, id: number) => void
scrollToLogInstance?: LogInstance
updated: number
}
const LogChunk = (props: LogChunkProps) => {
const { id, text, logLineCount } = props
const [scrollToLogInstance, setScrollToLogInstance] = useState(
props.scrollToLogInstance
)
const rowText = clearErrorsAndWarningsHtmlWrapping(text)
const styles = logStyles()
const [expanded, setExpanded] = useState(props.expanded)
const [copied, setCopied] = useState(false)
useEffect(() => {
setExpanded(props.expanded)
}, [props.expanded])
useEffect(() => {
if (props.expanded !== expanded) {
setExpanded(props.expanded)
}
if (
props.scrollToLogInstance &&
props.scrollToLogInstance !== scrollToLogInstance
) {
setScrollToLogInstance(props.scrollToLogInstance)
}
}, [props])
useEffect(() => {
if (expanded && scrollToLogInstance) {
const { type, id } = scrollToLogInstance
const line = document.getElementById(`${type}_${id}`)
const logWrapper: HTMLDivElement | null =
document.querySelector(`#logWrapper`)
const logContainer: HTMLHeadElement | null =
document.querySelector(`#log_container`)
if (line && logWrapper && logContainer) {
line.className = classes.HighlightedLine
line.scrollIntoView({ behavior: 'smooth', block: 'start' })
setTimeout(() => {
line.classList.remove(classes.HighlightedLine)
setScrollToLogInstance(undefined)
}, 3000)
}
}
}, [expanded, scrollToLogInstance, props])
const { errors, warnings } = parseErrorsAndWarnings(text)
const getLineRange = (separator = ' ... ') =>
`${id * defaultChunkSize}${separator}${
(id + 1) * defaultChunkSize < logLineCount
? (id + 1) * defaultChunkSize
: logLineCount
}`
return (
<div onClick={(evt) => props.onClick(evt, id)}>
<button className={classes.ChunkHeader}>
<Typography variant="subtitle1">
<div className={classes.ChunkDetails}>
<span>{`Lines: ${getLineRange()}`}</span>
{copied ? (
<CheckIcon
className={[classes.Icon, classes.GreenIcon].join(' ')}
/>
) : (
<ContentCopyIcon
className={classes.Icon}
onClick={(evt: SyntheticEvent) => {
evt.stopPropagation()
navigator.clipboard.writeText(rowText)
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1000)
}}
/>
)}
<FileDownloadIcon
onClick={(evt: SyntheticEvent) => {
download(evt, rowText, `.${getLineRange('-')}`)
}}
/>
{errors && errors.length !== 0 && (
<ErrorOutline
color="error"
className={classes.Icon}
onClick={() => {
setScrollToLogInstance(errors[0])
}}
/>
)}
{warnings && warnings.length !== 0 && (
<Warning
className={[classes.Icon, classes.GreenIcon].join(' ')}
onClick={(evt) => {
if (expanded) evt.stopPropagation()
setScrollToLogInstance(warnings[0])
}}
/>
)}{' '}
<ExpandMoreIcon
className={classes.ChunkExpandIcon}
style={{
transform: expanded ? 'rotate(180deg)' : 'unset'
}}
/>
</div>
</Typography>
</button>
<div
className={classes.ChunkBody}
style={{
display: expanded ? 'block' : 'none'
}}
onClick={(evt) => {
evt.stopPropagation()
}}
>
<div
id={`log_container`}
className={[styles.expansionDescription, classes.LogContainer].join(
' '
)}
>
<Highlight className={'html'} innerHTML={true}>
{expanded ? text : ''}
</Highlight>
</div>
</div>
</div>
)
}
export default LogChunk

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