mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b62755f39 | ||
|
|
cb84c3ebbb | ||
|
|
526402fd73 | ||
|
|
1b234eb2b1 | ||
|
|
ef25eec11f | ||
| 63dd6813c0 | |||
| 299512135d | |||
|
|
a1a182698e | ||
|
|
4be692b24b | ||
|
|
d2ddd8aaca | ||
|
|
3a45e8f525 | ||
|
|
c0e2f55a7b | ||
|
|
aa027414ed | ||
|
|
8c4c52b1a9 | ||
|
|
ff420434ae | ||
|
|
65e6de9663 | ||
|
|
2e53d43e11 | ||
|
|
3795f748a7 | ||
|
|
e024a92f16 | ||
|
|
92fda183f3 | ||
|
|
6f2e6efd03 | ||
|
|
3b4e9d20d4 | ||
|
|
4a67d0c63a | ||
|
|
dea204e3c5 | ||
|
|
5f9e83759c | ||
|
|
fefe63deb1 | ||
| ddd179bbee | |||
| a10b87930c | |||
| 496247d0b9 | |||
| eeb63b330c | |||
|
|
1108d3dd7b | ||
|
|
7edb47a4cb | ||
|
|
451cb4f6dd | ||
|
|
0b759a5594 | ||
|
|
5338ffb211 | ||
| e42fdd3575 | |||
| b10e932605 | |||
| e54a09db19 | |||
| 4c35e04802 | |||
| b5f595a25c | |||
|
|
a131adbae7 | ||
|
|
a20c3b9719 | ||
|
|
eee3a7b084 | ||
|
|
9c3da56901 | ||
|
|
7e6524d7e4 | ||
|
|
0ea2690616 | ||
|
|
b369759f0f | ||
|
|
ac9a835c5a | ||
|
|
e290751c87 | ||
| e516b7716d | |||
| f3dfc7083f | |||
| 7d916ec3e9 | |||
| 70f279a49c | |||
| 66a3537271 | |||
| ca64c13909 | |||
| 0a73a35547 | |||
| a75edbaa32 | |||
| 4ddfec0403 | |||
| 35439d7d51 | |||
| 907aa485fd | |||
| 888627e1c8 | |||
| 9cb9e2dd33 | |||
| 54d4bf835d | |||
| 67fe298fd5 | |||
| 97ecfdc955 | |||
| 5b319f9ad1 | |||
| be8635ccc5 | |||
| f863b81a7d | |||
| bdf63df1d9 | |||
| 4c6b9c5e93 | |||
|
|
a2d1396057 | ||
|
|
b2f21eb3ac | ||
|
|
71bcbb9134 | ||
|
|
c86f0feff8 | ||
|
|
d3d2ab9a36 | ||
| 5cc85b57f8 | |||
|
|
ae0fc0c48c | ||
|
|
555c5d54e2 | ||
| 1b5859ee37 | |||
| 65380be2f3 | |||
|
|
1933be15c2 | ||
|
|
56b20beb8c | ||
|
|
bfc5ac6a4f | ||
|
|
6376173de0 | ||
|
|
3130fbeff0 | ||
|
|
01e9a1d9e9 | ||
|
|
2119e9de9a | ||
|
|
87dbab98f6 | ||
|
|
1bf122a0a2 | ||
|
|
5d5d6ce326 | ||
|
|
620eddb713 | ||
|
|
3c92034da3 | ||
|
|
f6dc74f16b | ||
|
|
8c48d00d21 | ||
|
|
48ff8d73d4 | ||
| eb397b15c2 | |||
| eb569c7b82 | |||
| 99a1107364 | |||
| 91d29cb127 | |||
|
|
1e2c08a8d3 | ||
|
|
473fbd62c0 | ||
|
|
b1a0fe7060 | ||
| dde293c852 | |||
| f738a6d7a3 | |||
|
|
3e0a2de2ad | ||
|
|
91cb7bd946 | ||
|
|
a501a300dc | ||
|
|
b446baa822 | ||
| 9023cf33b5 | |||
| 23b6692f02 | |||
|
|
6de91618ff | ||
|
|
e06d66f312 | ||
|
|
1ffaf2e0ef | ||
|
|
393d3327db | ||
|
|
14cfb9a663 | ||
|
|
dd1f2b3ed7 | ||
|
|
9f5dbbc8da | ||
|
|
9423bb2b23 | ||
|
|
5bfcdc4dbb | ||
|
|
ecd8ed9032 | ||
|
|
a8d89ff1d6 | ||
|
|
8702a4e8fd | ||
| ab222cbaab | |||
|
|
5f06132ece | ||
|
|
56c80b0979 | ||
| 158acf1f97 | |||
| 1790e10fc1 | |||
| 53854d0012 | |||
|
|
81501d17ab | ||
|
|
11a7f920f1 | ||
|
|
e359265c4b | ||
|
|
8e7c9e671c | ||
|
|
c830f44e29 | ||
| de9ed15286 | |||
| 325285f447 | |||
| 16856165fb | |||
| 058b3b0081 | |||
| 9d5a5e051f | |||
| 2c704a544f | |||
| 6d6bda5626 | |||
| dffe6d7121 | |||
| b4443819d4 | |||
| e5a7674fa1 | |||
| 596ada7ca8 | |||
| f561ba4bf0 | |||
| c58666eb81 | |||
| 5df619b3f6 | |||
| 07295aa151 | |||
| 194eaec7d4 | |||
| fa63dc071b | |||
| e8c21a43b2 | |||
| 1413b18508 | |||
| dfbd155711 | |||
| 4fcc191ce9 | |||
| d000f7508f | |||
| 5652325452 | |||
| 0781ddd64e | |||
| 7be77cc38a | |||
| 98b8a75148 | |||
| 72a3197a06 | |||
| fce05d6959 | |||
| 1aec3abd28 | |||
| 9136c95013 | |||
|
|
89b32e70ff | ||
| 01713440a4 | |||
| 540f54fb77 | |||
| bf906aa544 | |||
| 797c2bcc39 | |||
| 1103ffe07b | |||
| e5200c1000 | |||
| 38a7db8514 | |||
| 39fc908de1 | |||
| be009d5b02 | |||
| 6bea1f7666 |
84
.all-contributorsrc
Normal file
84
.all-contributorsrc
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"projectName": "server",
|
||||
"projectOwner": "sasjs",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": true,
|
||||
"commitConvention": "angular",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "saadjutt01",
|
||||
"name": "Saad Jutt",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4",
|
||||
"profile": "https://github.com/saadjutt01",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sabhas",
|
||||
"name": "Sabir Hassan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4",
|
||||
"profile": "https://github.com/sabhas",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "YuryShkoda",
|
||||
"name": "Yury Shkoda",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4",
|
||||
"profile": "https://www.erudicat.com/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "medjedovicm",
|
||||
"name": "Mihajlo Medjedovic",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4",
|
||||
"profile": "https://github.com/medjedovicm",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "allanbowe",
|
||||
"name": "Allan Bowe",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4",
|
||||
"profile": "https://4gl.io/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "VladislavParhomchik",
|
||||
"name": "Vladislav Parhomchik",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4",
|
||||
"profile": "https://github.com/VladislavParhomchik",
|
||||
"contributions": [
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kknapen",
|
||||
"name": "Koen Knapen",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/78609432?v=4",
|
||||
"profile": "https://github.com/kknapen",
|
||||
"contributions": [
|
||||
"userTesting"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"skipCi": true
|
||||
}
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [sasjs]
|
||||
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -55,6 +55,9 @@ jobs:
|
||||
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
|
||||
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
|
||||
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
|
||||
RUN_TIMES: 'sas,js'
|
||||
SAS_PATH: '/some/path/to/sas'
|
||||
NODE_PATH: '/some/path/to/node'
|
||||
|
||||
- name: Build Package
|
||||
working-directory: ./api
|
||||
|
||||
171
CHANGELOG.md
171
CHANGELOG.md
@@ -1,3 +1,174 @@
|
||||
## [0.11.4](https://github.com/sasjs/server/compare/v0.11.3...v0.11.4) (2022-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** missing cookie flags are added ([526402f](https://github.com/sasjs/server/commit/526402fd73407ee4fa2d31092111a7e6a1741487))
|
||||
|
||||
## [0.11.3](https://github.com/sasjs/server/compare/v0.11.2...v0.11.3) (2022-07-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* filePath fix in code.js file for windows ([2995121](https://github.com/sasjs/server/commit/299512135d77c2ac9e34853cf35aee6f2e1d4da4))
|
||||
|
||||
## [0.11.2](https://github.com/sasjs/server/compare/v0.11.1...v0.11.2) (2022-07-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply icon option only for sas.exe ([d2ddd8a](https://github.com/sasjs/server/commit/d2ddd8aacadfdd143026881f2c6ae8c6b277610a))
|
||||
|
||||
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bank operator ([aa02741](https://github.com/sasjs/server/commit/aa027414ed3ce51f1014ef36c4191e064b2e963d))
|
||||
* ensuring nosplash option only applies for sas.exe ([65e6de9](https://github.com/sasjs/server/commit/65e6de966383fe49a919b1f901d77c7f1e402c9b)), closes [#229](https://github.com/sasjs/server/issues/229)
|
||||
|
||||
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **logs:** logs location is configurable ([e024a92](https://github.com/sasjs/server/commit/e024a92f165990e08db8aa26ee326dbcb30e2e46))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **logs:** logs to file with rotating + code split into files ([92fda18](https://github.com/sasjs/server/commit/92fda183f3f0f3956b7c791669eb8dd52c389d1b))
|
||||
|
||||
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add authorize middleware for appStreams ([e54a09d](https://github.com/sasjs/server/commit/e54a09db19ec8690e54a40760531a4e06d250974))
|
||||
* add isAdmin attribute to return response of get session and login requests ([bdf63df](https://github.com/sasjs/server/commit/bdf63df1d915892486005ec904807749786b1c0c))
|
||||
* add permission authorization middleware to only specific routes ([f3dfc70](https://github.com/sasjs/server/commit/f3dfc7083fbfb4b447521341b1a86730fb90b4c0))
|
||||
* bumping core and running lint ([a2d1396](https://github.com/sasjs/server/commit/a2d13960578014312d2cb5e03145bfd1829d99ec))
|
||||
* controller fixed for deleting permission ([b5f595a](https://github.com/sasjs/server/commit/b5f595a25c50550d62482409353c7629c5a5c3e0))
|
||||
* do not show admin users in add permission modal ([a75edba](https://github.com/sasjs/server/commit/a75edbaa327ec2af49523c13996ac283061da7d8))
|
||||
* export GroupResponse interface ([38a7db8](https://github.com/sasjs/server/commit/38a7db8514de0acd94d74ba96bc1efb732add30c))
|
||||
* move permission filter modal to separate file and icons for different actions ([d000f75](https://github.com/sasjs/server/commit/d000f7508f6d7384afffafee4179151fca802ca8))
|
||||
* principalId type changed to number from any ([4fcc191](https://github.com/sasjs/server/commit/4fcc191ce9edc7e4dcd8821fb8019f4eea5db4ea))
|
||||
* remove clientId from principal types ([0781ddd](https://github.com/sasjs/server/commit/0781ddd64e3b5e5ca39647bb4e4e1a9332a0f4f8))
|
||||
* remove duplicates principals from permission filter modal ([5b319f9](https://github.com/sasjs/server/commit/5b319f9ad1f941b306db6b9473a2128b2e42bf76))
|
||||
* show loading spinner in studio while executing code ([496247d](https://github.com/sasjs/server/commit/496247d0b9975097a008cf4d3a999d77648fd930))
|
||||
* show permission component only in server mode ([f863b81](https://github.com/sasjs/server/commit/f863b81a7d40a1296a061ec93946f204382af2c3))
|
||||
* update permission model ([39fc908](https://github.com/sasjs/server/commit/39fc908de1945f2aaea18d14e6bce703f6bf0c06))
|
||||
* update permission response ([e516b77](https://github.com/sasjs/server/commit/e516b7716da5ff7e23350a5f77cfa073b1171175))
|
||||
* **web:** only admin should be able to add, update or delete permission ([be8635c](https://github.com/sasjs/server/commit/be8635ccc5eb34c3f0a5951c8a0421292ef69c97))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add api endpoint for deleting permission ([0171344](https://github.com/sasjs/server/commit/01713440a4fa661b76368785c0ca731f096ac70a))
|
||||
* add api endpoint for updating permission setting ([540f54f](https://github.com/sasjs/server/commit/540f54fb77b364822da7889dbe75c02242f48a59))
|
||||
* add authorize middleware for validating permissions ([7d916ec](https://github.com/sasjs/server/commit/7d916ec3e9ef579dde1b73015715cd01098c2018))
|
||||
* add basic UI for settings and permissions ([5652325](https://github.com/sasjs/server/commit/56523254525a66e756196e90b39a2b8cdadc1518))
|
||||
* add documentation link under usename dropdown menu ([eeb63b3](https://github.com/sasjs/server/commit/eeb63b330c292afcdd5c8f006882b224c4235068))
|
||||
* add permission model ([6bea1f7](https://github.com/sasjs/server/commit/6bea1f76668ddb070ad95b3e02c31238af67c346))
|
||||
* add UI for updating permission ([e8c21a4](https://github.com/sasjs/server/commit/e8c21a43b215f5fced0463b70747cda1191a4e01))
|
||||
* add validation for registering permission ([e5200c1](https://github.com/sasjs/server/commit/e5200c1000903185dfad9ee49c99583e473c4388))
|
||||
* add, remove and update permissions from web component ([97ecfdc](https://github.com/sasjs/server/commit/97ecfdc95563c72dbdecaebcb504e5194250a763))
|
||||
* added get authorizedRoutes api endpoint ([b10e932](https://github.com/sasjs/server/commit/b10e9326058193dd65a57fab2d2f05b7b06096e7))
|
||||
* created modal for adding permission ([1413b18](https://github.com/sasjs/server/commit/1413b1850838ecc988ab289da4541bde36a9a346))
|
||||
* defined register permission and get all permissions api endpoints ([1103ffe](https://github.com/sasjs/server/commit/1103ffe07b88496967cb03683b08f058ca3bbb9f))
|
||||
* update swagger docs ([797c2bc](https://github.com/sasjs/server/commit/797c2bcc39005a05a995be15a150d584fecae259))
|
||||
|
||||
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* removed secrets from env variables ([9c3da56](https://github.com/sasjs/server/commit/9c3da56901672a818f54267f9defc9f4701ab7fb))
|
||||
|
||||
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deploy:** extract first json from zip file ([e290751](https://github.com/sasjs/server/commit/e290751c872d24009482871a8c398e834357dcde))
|
||||
|
||||
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes ([5cc85b5](https://github.com/sasjs/server/commit/5cc85b57f80b13296156811fe966d7b37d45f213))
|
||||
|
||||
## [0.8.1](https://github.com/sasjs/server/compare/v0.8.0...v0.8.1) (2022-06-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make CA_ROOT optional in getCertificates method ([1b5859e](https://github.com/sasjs/server/commit/1b5859ee37ae73c419115b9debfd5141a79733de))
|
||||
* update /logout route to /SASLogon/logout ([65380be](https://github.com/sasjs/server/commit/65380be2f3945bae559f1749064845b514447a53))
|
||||
|
||||
# [0.8.0](https://github.com/sasjs/server/compare/v0.7.3...v0.8.0) (2022-06-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **certs:** ENV variables updated and set CA Root for HTTPS server ([2119e9d](https://github.com/sasjs/server/commit/2119e9de9ab1e5ce1222658f554ac74f4f35cf4d))
|
||||
|
||||
## [0.7.3](https://github.com/sasjs/server/compare/v0.7.2...v0.7.3) (2022-06-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path descriptions and defaults ([5d5d6ce](https://github.com/sasjs/server/commit/5d5d6ce3265a43af2e22bcd38cda54fafaf7b3ef))
|
||||
|
||||
## [0.7.2](https://github.com/sasjs/server/compare/v0.7.1...v0.7.2) (2022-06-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* removing UTF-8 options from commandline. There appears to be no reliable way to enforce ([f6dc74f](https://github.com/sasjs/server/commit/f6dc74f16bddafa1de9c83c2f27671a241abdad4))
|
||||
|
||||
## [0.7.1](https://github.com/sasjs/server/compare/v0.7.0...v0.7.1) (2022-06-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default runtime should be sas ([91d29cb](https://github.com/sasjs/server/commit/91d29cb1272c28afbceaf39d1e0a87e17fbfdcd6))
|
||||
* **Studio:** default selection of runtime fixed ([eb569c7](https://github.com/sasjs/server/commit/eb569c7b827c872ed2c4bc114559b97d87fd2aa0))
|
||||
* webout path fixed in code.js when running on windows ([99a1107](https://github.com/sasjs/server/commit/99a110736448f66f99a512396b268fc31a3feef0))
|
||||
|
||||
# [0.7.0](https://github.com/sasjs/server/compare/v0.6.1...v0.7.0) (2022-06-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add runtimes to global process object ([194eaec](https://github.com/sasjs/server/commit/194eaec7d4a561468f83bf6efce484909ee532eb))
|
||||
* code fixes for executing program from program path including file extension ([53854d0](https://github.com/sasjs/server/commit/53854d001279462104b24c0e59a8c94ab4938a94))
|
||||
* code/execute controller logic to handle different runtimes ([23b6692](https://github.com/sasjs/server/commit/23b6692f02e4afa33c9dc95d242eb8645c19d546))
|
||||
* convert single executeProgram method to two methods i.e. executeSASProgram and executeJSProgram ([c58666e](https://github.com/sasjs/server/commit/c58666eb81514de500519e7b96c1981778ec149b))
|
||||
* no need to stringify _webout in preProgramVarStatements, developer should have _webout as string in actual code ([9d5a5e0](https://github.com/sasjs/server/commit/9d5a5e051fd821295664ddb3a1fd64629894a44c))
|
||||
* pass _program to execute file without extension ([5df619b](https://github.com/sasjs/server/commit/5df619b3f63571e8e326261d8114869d33881d91))
|
||||
* refactor code for session selection in preUploadMiddleware function ([b444381](https://github.com/sasjs/server/commit/b4443819d42afecebc0f382c58afb9010d4775ef))
|
||||
* refactor code in executeFile method of session controller ([dffe6d7](https://github.com/sasjs/server/commit/dffe6d7121d569e5c7d13023c6ca68d8c901c88e))
|
||||
* refactor code in preUploadMiddleware function ([6d6bda5](https://github.com/sasjs/server/commit/6d6bda56267babde7b98cf69e32973d56d719f75))
|
||||
* refactor sas/js session controller classes to inherit from base session controller class ([2c704a5](https://github.com/sasjs/server/commit/2c704a544f4e31a8e8e833a9a62ba016bcfa6c7c))
|
||||
* **Studio:** style fix for runtime dropdown ([9023cf3](https://github.com/sasjs/server/commit/9023cf33b5fa4b13c2d5e9b80ae307df69c7fc02))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* configure child process with writeStream to write logs to log file ([058b3b0](https://github.com/sasjs/server/commit/058b3b00816e582e143953c2f0b8330bde2181b8))
|
||||
* conver single session controller to two controller i.e. SASSessionController and JSSessionController ([07295aa](https://github.com/sasjs/server/commit/07295aa151175db8c93eeef806fc3b7fde40ac72))
|
||||
* create and inject code for uploaded files to code.js ([1685616](https://github.com/sasjs/server/commit/16856165fb292dc9ffa897189ba105bd9f362267))
|
||||
* validate sasjs_runtimes env var ([596ada7](https://github.com/sasjs/server/commit/596ada7ca88798d6d71f6845633a006fd22438ea))
|
||||
|
||||
## [0.6.1](https://github.com/sasjs/server/compare/v0.6.0...v0.6.1) (2022-06-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* home page wording. Using fix to force previous change through.. ([8702a4e](https://github.com/sasjs/server/commit/8702a4e8fd1bbfaf4f426b75e8b85a87ede0e0b0))
|
||||
|
||||
# [0.6.0](https://github.com/sasjs/server/compare/v0.5.0...v0.6.0) (2022-06-16)
|
||||
|
||||
|
||||
|
||||
19
PULL_REQUEST_TEMPLATE.md
Normal file
19
PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Issue
|
||||
|
||||
Link any related issue(s) in this section.
|
||||
|
||||
## Intent
|
||||
|
||||
What this PR intends to achieve.
|
||||
|
||||
## Implementation
|
||||
|
||||
What code changes have been made to achieve the intent.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] Code is formatted correctly (`npm run lint:fix`).
|
||||
- [ ] Any new functionality has been unit tested.
|
||||
- [ ] All unit tests are passing (`npm test`).
|
||||
- [ ] All CI checks are green.
|
||||
- [ ] Reviewer is assigned.
|
||||
57
README.md
57
README.md
@@ -1,5 +1,11 @@
|
||||
# SASjs Server
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors-)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides:
|
||||
|
||||
- Virtual filesystem for storing SAS programs and other content
|
||||
@@ -61,10 +67,14 @@ MODE=
|
||||
# Path to SAS executable (sas.exe / sas.sh)
|
||||
SAS_PATH=/path/to/sas/executable.exe
|
||||
|
||||
# Path to Node.js executable
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
|
||||
# Path to working directory
|
||||
# This location is for SAS WORK, staged files, DRIVE, configuration etc
|
||||
SASJS_ROOT=./sasjs_root
|
||||
|
||||
|
||||
# options: [http|https] default: http
|
||||
PROTOCOL=
|
||||
|
||||
@@ -89,15 +99,12 @@ SASV9_OPTIONS= -NOXCMD
|
||||
## Additional Web Server Options
|
||||
#
|
||||
|
||||
# ENV variables required for PROTOCOL: `https`
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
# ENV variables for PROTOCOL: `https`
|
||||
PRIVATE_KEY=privkey.pem (required)
|
||||
CERT_CHAIN=certificate.pem (required)
|
||||
CA_ROOT=fullchain.pem (optional)
|
||||
|
||||
# ENV variables required for MODE: `server`
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
SESSION_SECRET=<secret>
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
|
||||
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
|
||||
@@ -129,6 +136,16 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json
|
||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||
LOG_FORMAT_MORGAN=
|
||||
|
||||
# This location is for server logs with classical UNIX logrotate behavior
|
||||
LOG_LOCATION=./sasjs_root/logs
|
||||
|
||||
# A comma separated string that defines the available runTimes.
|
||||
# Priority is given to the runtime that comes first in the string.
|
||||
# Possible options at the moment are sas and js
|
||||
|
||||
# options: [sas,js|js,sas|sas|js] default:sas
|
||||
RUN_TIMES=
|
||||
|
||||
```
|
||||
|
||||
## Persisting the Session
|
||||
@@ -185,3 +202,29 @@ The following credentials can be used for the initial connection to SASjs/server
|
||||
- CLIENTID: `clientID1`
|
||||
- USERNAME: `secretuser`
|
||||
- PASSWORD: `secretpassword`
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Saad Jutt</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Tests">⚠️</a></td>
|
||||
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=sabhas" title="Tests">⚠️</a></td>
|
||||
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Tests">⚠️</a></td>
|
||||
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Tests">⚠️</a></td>
|
||||
<td align="center"><a href="https://4gl.io/"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vladislav Parhomchik</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=VladislavParhomchik" title="Tests">⚠️</a></td>
|
||||
<td align="center"><a href="https://github.com/kknapen"><img src="https://avatars.githubusercontent.com/u/78609432?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Koen Knapen</b></sub></a><br /><a href="#userTesting-kknapen" title="User Testing">📓</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
@@ -4,20 +4,21 @@ WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
|
||||
|
||||
PROTOCOL=[http|https] default considered as http
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
CERT_CHAIN=certificate.pem
|
||||
CA_ROOT=fullchain.pem
|
||||
|
||||
PORT=[5000] default value is 5000
|
||||
|
||||
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
|
||||
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
||||
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
SESSION_SECRET=<secret>
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
|
||||
RUN_TIMES=[sas|js|sas,js|js,sas] default considered as sas
|
||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
|
||||
SASJS_ROOT=./sasjs_root
|
||||
|
||||
LOG_FORMAT_MORGAN=common
|
||||
LOG_FORMAT_MORGAN=common
|
||||
LOG_LOCATION=./sasjs_root/logs
|
||||
@@ -1 +1 @@
|
||||
v16.14.0
|
||||
v16.15.1
|
||||
1266
api/package-lock.json
generated
1266
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
|
||||
"prestart": "npm run initial",
|
||||
"prebuild": "npm run initial",
|
||||
"start": "nodemon ./src/server.ts",
|
||||
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||
"start:prod": "node ./build/src/server.js",
|
||||
"build": "rimraf build && tsc",
|
||||
"postbuild": "npm run copy:files",
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.27.3",
|
||||
"@sasjs/core": "^4.31.3",
|
||||
"@sasjs/utils": "2.42.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-mongo": "^4.6.0",
|
||||
@@ -63,6 +63,7 @@
|
||||
"mongoose-sequence": "^5.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.3",
|
||||
"rotating-file-stream": "^3.0.4",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"url": "^0.10.3"
|
||||
|
||||
@@ -47,41 +47,6 @@ components:
|
||||
- userId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: 'Username for user'
|
||||
example: secretuser
|
||||
password:
|
||||
type: string
|
||||
description: 'Password for user'
|
||||
example: secretpassword
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
type: object
|
||||
additionalProperties: false
|
||||
AuthorizeResponse:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Authorization code'
|
||||
example: someRandomCryptoString
|
||||
required:
|
||||
- code
|
||||
type: object
|
||||
additionalProperties: false
|
||||
AuthorizePayload:
|
||||
properties:
|
||||
clientId:
|
||||
type: string
|
||||
description: 'Client ID'
|
||||
example: clientID1
|
||||
required:
|
||||
- clientId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
ClientPayload:
|
||||
properties:
|
||||
clientId:
|
||||
@@ -139,14 +104,24 @@ components:
|
||||
- httpHeaders
|
||||
type: object
|
||||
additionalProperties: false
|
||||
ExecuteSASCodePayload:
|
||||
RunTimeType:
|
||||
enum:
|
||||
- sas
|
||||
- js
|
||||
type: string
|
||||
ExecuteCodePayload:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Code of SAS program'
|
||||
example: '* SAS Code HERE;'
|
||||
description: 'Code of program'
|
||||
example: '* Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'runtime for program'
|
||||
example: js
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
MemberType.folder:
|
||||
@@ -304,10 +279,13 @@ components:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
type: object
|
||||
additionalProperties: false
|
||||
GroupResponse:
|
||||
@@ -430,13 +408,13 @@ components:
|
||||
type: object
|
||||
Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__:
|
||||
properties:
|
||||
id:
|
||||
description: 'The string version of this documents _id.'
|
||||
_id:
|
||||
$ref: '#/components/schemas/_LeanDocument__LeanDocument_T__'
|
||||
description: 'This documents _id.'
|
||||
__v:
|
||||
description: 'This documents __v.'
|
||||
id:
|
||||
description: 'The string version of this documents _id.'
|
||||
type: object
|
||||
description: 'From T, pick a set of properties whose keys are in the union K'
|
||||
Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_:
|
||||
@@ -458,11 +436,26 @@ components:
|
||||
type: array
|
||||
protocol:
|
||||
type: string
|
||||
runTimes:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- mode
|
||||
- cors
|
||||
- whiteList
|
||||
- protocol
|
||||
- runTimes
|
||||
type: object
|
||||
additionalProperties: false
|
||||
AuthorizedRoutesResponse:
|
||||
properties:
|
||||
URIs:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- URIs
|
||||
type: object
|
||||
additionalProperties: false
|
||||
ExecuteReturnJsonPayload:
|
||||
@@ -473,6 +466,106 @@ components:
|
||||
example: /Public/somefolder/some.file
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: 'Username for user'
|
||||
example: secretuser
|
||||
password:
|
||||
type: string
|
||||
description: 'Password for user'
|
||||
example: secretpassword
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
type: object
|
||||
additionalProperties: false
|
||||
AuthorizeResponse:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Authorization code'
|
||||
example: someRandomCryptoString
|
||||
required:
|
||||
- code
|
||||
type: object
|
||||
additionalProperties: false
|
||||
AuthorizePayload:
|
||||
properties:
|
||||
clientId:
|
||||
type: string
|
||||
description: 'Client ID'
|
||||
example: clientID1
|
||||
required:
|
||||
- clientId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
PermissionDetailsResponse:
|
||||
properties:
|
||||
permissionId:
|
||||
type: number
|
||||
format: double
|
||||
uri:
|
||||
type: string
|
||||
setting:
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
group:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
required:
|
||||
- permissionId
|
||||
- uri
|
||||
- setting
|
||||
type: object
|
||||
additionalProperties: false
|
||||
PermissionSetting:
|
||||
enum:
|
||||
- Grant
|
||||
- Deny
|
||||
type: string
|
||||
PrincipalType:
|
||||
enum:
|
||||
- user
|
||||
- group
|
||||
type: string
|
||||
RegisterPermissionPayload:
|
||||
properties:
|
||||
uri:
|
||||
type: string
|
||||
description: 'Name of affected resource'
|
||||
example: /SASjsApi/code/execute
|
||||
setting:
|
||||
$ref: '#/components/schemas/PermissionSetting'
|
||||
description: 'The indication of whether (and to what extent) access is provided'
|
||||
example: Grant
|
||||
principalType:
|
||||
$ref: '#/components/schemas/PrincipalType'
|
||||
description: 'Indicates the type of principal'
|
||||
example: user
|
||||
principalId:
|
||||
type: number
|
||||
format: double
|
||||
description: 'The id of user or group to which a rule is assigned.'
|
||||
example: 123
|
||||
required:
|
||||
- uri
|
||||
- setting
|
||||
- principalType
|
||||
- principalId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UpdatePermissionPayload:
|
||||
properties:
|
||||
setting:
|
||||
$ref: '#/components/schemas/PermissionSetting'
|
||||
description: 'The indication of whether (and to what extent) access is provided'
|
||||
example: Grant
|
||||
required:
|
||||
- setting
|
||||
type: object
|
||||
additionalProperties: false
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
@@ -543,86 +636,6 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
summary: 'Render index.html'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
/SASLogon/login:
|
||||
post:
|
||||
operationId: Login
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
user: {properties: {displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [displayName, username, id], type: object}
|
||||
loggedIn: {type: boolean}
|
||||
required:
|
||||
- user
|
||||
- loggedIn
|
||||
type: object
|
||||
summary: 'Accept a valid username/password'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginPayload'
|
||||
/SASLogon/authorize:
|
||||
post:
|
||||
operationId: Authorize
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizeResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {code: someRandomCryptoString}
|
||||
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizePayload'
|
||||
/logout:
|
||||
get:
|
||||
operationId: Logout
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
summary: 'Accept a valid username/password'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
/SASjsApi/client:
|
||||
post:
|
||||
operationId: CreateClient
|
||||
@@ -651,7 +664,7 @@ paths:
|
||||
$ref: '#/components/schemas/ClientPayload'
|
||||
/SASjsApi/code/execute:
|
||||
post:
|
||||
operationId: ExecuteSASCode
|
||||
operationId: ExecuteCode
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
@@ -672,7 +685,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecuteSASCodePayload'
|
||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
||||
/SASjsApi/drive/deploy:
|
||||
post:
|
||||
operationId: Deploy
|
||||
@@ -978,7 +991,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{id: 123, username: johnusername, displayName: John}, {id: 456, username: starkusername, displayName: Stark}]
|
||||
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
|
||||
summary: 'Get list of all users (username, displayname). All users can request this.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1401,12 +1414,30 @@ paths:
|
||||
$ref: '#/components/schemas/InfoResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http}
|
||||
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http, runTimes: [sas, js]}
|
||||
summary: 'Get server info (mode, cors, whiteList, protocol).'
|
||||
tags:
|
||||
- Info
|
||||
security: []
|
||||
parameters: []
|
||||
/SASjsApi/info/authorizedRoutes:
|
||||
get:
|
||||
operationId: AuthorizedRoutes
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizedRoutesResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {URIs: [/AppStream, /SASjsApi/stp/execute]}
|
||||
summary: 'Get authorized routes.'
|
||||
tags:
|
||||
- Info
|
||||
security: []
|
||||
parameters: []
|
||||
/SASjsApi/session:
|
||||
get:
|
||||
operationId: Session
|
||||
@@ -1419,7 +1450,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 123, username: johnusername, displayName: John}
|
||||
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
||||
summary: 'Get session info (username).'
|
||||
tags:
|
||||
- Session
|
||||
@@ -1439,8 +1470,8 @@ paths:
|
||||
anyOf:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. Setting _debug=131 will\ncause the log to be streamed in the output.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response headers can be adjusted using the mfs_httpheader() macro. Any\nfile type can be returned, including binary files such as zip or xls.\n\nIf _debug is >= 131, response headers will contain Content-Type: 'text/plain'\n\nThis behaviour differs for POST requests, in which case the response is\nalways JSON."
|
||||
summary: 'Execute Stored Program, return raw _webout content.'
|
||||
description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
summary: 'Execute a Stored Program, returns raw _webout content.'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
@@ -1448,13 +1479,13 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of SAS program'
|
||||
description: 'Location of SAS or JS code'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Public/somefolder/some.file
|
||||
example: /Projects/myApp/some/program
|
||||
post:
|
||||
operationId: ExecuteReturnJson
|
||||
responses:
|
||||
@@ -1467,8 +1498,8 @@ paths:
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {status: success, _webout: 'webout content', log: [], httpHeaders: {Content-type: application/zip, Cache-Control: 'public, max-age=1000'}}
|
||||
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. In any case, the log is\nalways returned in the log object.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response will be a JSON object with the following root attributes: log,\nwebout, headers.\n\nThe webout will be a nested JSON object ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content.\n\nResponse headers from the mfs_httpheader macro are simply listed in the\nheaders object, for POST requests they have no effect on the actual\nresponse header."
|
||||
summary: 'Execute Stored Program, return JSON'
|
||||
description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms\n\nThe response will be a JSON object with the following root attributes:\nlog, webout, headers.\n\nThe webout attribute will be nested JSON ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content."
|
||||
summary: 'Execute a Stored Program, return a JSON object'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
@@ -1476,32 +1507,218 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of SAS program'
|
||||
description: 'Location of SAS or JS code'
|
||||
in: query
|
||||
name: _program
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
example: /Public/somefolder/some.file
|
||||
example: /Projects/myApp/some/program
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecuteReturnJsonPayload'
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
summary: 'Render index.html'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
/SASLogon/login:
|
||||
post:
|
||||
operationId: Login
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object}
|
||||
loggedIn: {type: boolean}
|
||||
required:
|
||||
- user
|
||||
- loggedIn
|
||||
type: object
|
||||
summary: 'Accept a valid username/password'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginPayload'
|
||||
/SASLogon/authorize:
|
||||
post:
|
||||
operationId: Authorize
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizeResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {code: someRandomCryptoString}
|
||||
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizePayload'
|
||||
/SASLogon/logout:
|
||||
get:
|
||||
operationId: Logout
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
summary: 'Destroy the session stored in cookies'
|
||||
tags:
|
||||
- Web
|
||||
security: []
|
||||
parameters: []
|
||||
/SASjsApi/permission:
|
||||
get:
|
||||
operationId: GetAllPermissions
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, uri: /SASjsApi/code/execute, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
|
||||
summary: 'Get list of all permissions (uri, setting and userDetail).'
|
||||
tags:
|
||||
- Permission
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
post:
|
||||
operationId: CreatePermission
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Create a new permission. Admin only.'
|
||||
tags:
|
||||
- Permission
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterPermissionPayload'
|
||||
'/SASjsApi/permission/{permissionId}':
|
||||
patch:
|
||||
operationId: UpdatePermission
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Update permission setting. Admin only'
|
||||
tags:
|
||||
- Permission
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The permission''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdatePermissionPayload'
|
||||
delete:
|
||||
operationId: DeletePermission
|
||||
responses:
|
||||
'204':
|
||||
description: 'No content'
|
||||
summary: 'Delete a permission. Admin only.'
|
||||
tags:
|
||||
- Permission
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
servers:
|
||||
-
|
||||
url: /
|
||||
tags:
|
||||
-
|
||||
name: Info
|
||||
description: 'Get Server Info'
|
||||
description: 'Get Server Information'
|
||||
-
|
||||
name: Session
|
||||
description: 'Get Session information'
|
||||
-
|
||||
name: User
|
||||
description: 'Operations about users'
|
||||
description: 'Operations with users'
|
||||
-
|
||||
name: Permission
|
||||
description: 'Operations about permissions'
|
||||
-
|
||||
name: Client
|
||||
description: 'Operations about clients'
|
||||
@@ -1510,16 +1727,16 @@ tags:
|
||||
description: 'Operations about auth'
|
||||
-
|
||||
name: Drive
|
||||
description: 'Operations about drive'
|
||||
description: 'Operations on SASjs Drive'
|
||||
-
|
||||
name: Group
|
||||
description: 'Operations about group'
|
||||
description: 'Operations on groups and group memberships'
|
||||
-
|
||||
name: STP
|
||||
description: 'Operations about STP'
|
||||
description: 'Execution of Stored Programs'
|
||||
-
|
||||
name: CODE
|
||||
description: 'Operations on SAS code'
|
||||
description: 'Execution of code (various runtimes are supported)'
|
||||
-
|
||||
name: Web
|
||||
description: 'Operations on Web'
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
readFile,
|
||||
SASJsFileType
|
||||
} from '@sasjs/utils'
|
||||
import { apiRoot, sysInitCompiledPath } from '../src/utils'
|
||||
import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
|
||||
|
||||
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
listFilesInFolder
|
||||
} from '@sasjs/utils'
|
||||
|
||||
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils'
|
||||
import {
|
||||
apiRoot,
|
||||
sasJSCoreMacros,
|
||||
sasJSCoreMacrosInfo
|
||||
} from '../src/utils/file'
|
||||
|
||||
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||
|
||||
|
||||
21
api/src/app-modules/configureCors.ts
Normal file
21
api/src/app-modules/configureCors.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Express } from 'express'
|
||||
import cors from 'cors'
|
||||
import { CorsType } from '../utils'
|
||||
|
||||
export const configureCors = (app: Express) => {
|
||||
const { CORS, WHITELIST } = process.env
|
||||
|
||||
if (CORS === CorsType.ENABLED) {
|
||||
const whiteList: string[] = []
|
||||
WHITELIST?.split(' ')
|
||||
?.filter((url) => !!url)
|
||||
.forEach((url) => {
|
||||
if (url.startsWith('http'))
|
||||
// removing trailing slash of URLs listing for CORS
|
||||
whiteList.push(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
console.log('All CORS Requests are enabled for:', whiteList)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
}
|
||||
32
api/src/app-modules/configureExpressSession.ts
Normal file
32
api/src/app-modules/configureExpressSession.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose from 'mongoose'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
|
||||
import { ModeType } from '../utils'
|
||||
import { cookieOptions } from '../app'
|
||||
|
||||
export const configureExpressSession = (app: Express) => {
|
||||
const { MODE } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
let store: MongoStore | undefined
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
store = MongoStore.create({
|
||||
client: mongoose.connection!.getClient() as any,
|
||||
collectionName: 'sessions'
|
||||
})
|
||||
}
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.secrets.SESSION_SECRET,
|
||||
saveUninitialized: false, // don't create session until something stored
|
||||
resave: false, //don't save session if unmodified
|
||||
store,
|
||||
cookie: cookieOptions
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
33
api/src/app-modules/configureLogger.ts
Normal file
33
api/src/app-modules/configureLogger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'path'
|
||||
import { Express } from 'express'
|
||||
import morgan from 'morgan'
|
||||
import { createStream } from 'rotating-file-stream'
|
||||
import { generateTimestamp } from '@sasjs/utils'
|
||||
import { getLogFolder } from '../utils'
|
||||
|
||||
export const configureLogger = (app: Express) => {
|
||||
const { LOG_FORMAT_MORGAN } = process.env
|
||||
|
||||
let options
|
||||
if (
|
||||
process.env.NODE_ENV !== 'development' &&
|
||||
process.env.NODE_ENV !== 'test'
|
||||
) {
|
||||
const timestamp = generateTimestamp()
|
||||
const filename = `${timestamp}.log`
|
||||
const logsFolder = getLogFolder()
|
||||
|
||||
// create a rotating write stream
|
||||
var accessLogStream = createStream(filename, {
|
||||
interval: '1d', // rotate daily
|
||||
path: logsFolder
|
||||
})
|
||||
|
||||
console.log('Writing Logs to :', path.join(logsFolder, filename))
|
||||
|
||||
options = { stream: accessLogStream }
|
||||
}
|
||||
|
||||
// setup the logger
|
||||
app.use(morgan(LOG_FORMAT_MORGAN as string, options))
|
||||
}
|
||||
26
api/src/app-modules/configureSecurity.ts
Normal file
26
api/src/app-modules/configureSecurity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Express } from 'express'
|
||||
import { getEnvCSPDirectives } from '../utils/parseHelmetConfig'
|
||||
import { HelmetCoepType, ProtocolType } from '../utils'
|
||||
import helmet from 'helmet'
|
||||
|
||||
export const configureSecurity = (app: Express) => {
|
||||
const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env
|
||||
|
||||
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
|
||||
HELMET_CSP_CONFIG_PATH
|
||||
)
|
||||
if (PROTOCOL === ProtocolType.HTTP)
|
||||
cspConfigJson['upgrade-insecure-requests'] = null
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
...cspConfigJson
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
|
||||
})
|
||||
)
|
||||
}
|
||||
4
api/src/app-modules/index.ts
Normal file
4
api/src/app-modules/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './configureCors'
|
||||
export * from './configureExpressSession'
|
||||
export * from './configureLogger'
|
||||
export * from './configureSecurity'
|
||||
122
api/src/app.ts
122
api/src/app.ts
@@ -1,30 +1,26 @@
|
||||
import path from 'path'
|
||||
import express, { ErrorRequestHandler } from 'express'
|
||||
import csrf from 'csurf'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
import morgan from 'morgan'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import dotenv from 'dotenv'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
|
||||
import {
|
||||
connectDB,
|
||||
copySASjsCore,
|
||||
CorsType,
|
||||
getWebBuildFolder,
|
||||
HelmetCoepType,
|
||||
instantiateLogger,
|
||||
loadAppStreamConfig,
|
||||
ModeType,
|
||||
ProtocolType,
|
||||
ReturnCode,
|
||||
setProcessVariables,
|
||||
setupFolders,
|
||||
verifyEnvVariables
|
||||
} from './utils'
|
||||
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
|
||||
import {
|
||||
configureCors,
|
||||
configureExpressSession,
|
||||
configureLogger,
|
||||
configureSecurity
|
||||
} from './app-modules'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -34,19 +30,7 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cookieParser())
|
||||
|
||||
const {
|
||||
MODE,
|
||||
CORS,
|
||||
WHITELIST,
|
||||
PROTOCOL,
|
||||
HELMET_CSP_CONFIG_PATH,
|
||||
HELMET_COEP,
|
||||
LOG_FORMAT_MORGAN
|
||||
} = process.env
|
||||
|
||||
app.use(morgan(LOG_FORMAT_MORGAN as string))
|
||||
const { PROTOCOL } = process.env
|
||||
|
||||
export const cookieOptions = {
|
||||
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||
@@ -54,79 +38,11 @@ export const cookieOptions = {
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
|
||||
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
|
||||
HELMET_CSP_CONFIG_PATH
|
||||
)
|
||||
if (PROTOCOL === ProtocolType.HTTP)
|
||||
cspConfigJson['upgrade-insecure-requests'] = null
|
||||
|
||||
/***********************************
|
||||
* CSRF Protection *
|
||||
***********************************/
|
||||
export const csrfProtection = csrf({ cookie: cookieOptions })
|
||||
|
||||
/***********************************
|
||||
* Handle security and origin *
|
||||
***********************************/
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
...cspConfigJson
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
|
||||
})
|
||||
)
|
||||
|
||||
/***********************************
|
||||
* Enabling CORS *
|
||||
***********************************/
|
||||
if (CORS === CorsType.ENABLED) {
|
||||
const whiteList: string[] = []
|
||||
WHITELIST?.split(' ')
|
||||
?.filter((url) => !!url)
|
||||
.forEach((url) => {
|
||||
if (url.startsWith('http'))
|
||||
// removing trailing slash of URLs listing for CORS
|
||||
whiteList.push(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
console.log('All CORS Requests are enabled for:', whiteList)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
|
||||
/***********************************
|
||||
* DB Connection & *
|
||||
* Express Sessions *
|
||||
* With Mongo Store *
|
||||
***********************************/
|
||||
if (MODE === ModeType.Server) {
|
||||
let store: MongoStore | undefined
|
||||
|
||||
// NOTE: when exporting app.js as agent for supertest
|
||||
// we should exclude connecting to the real database
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
|
||||
|
||||
store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
|
||||
}
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET as string,
|
||||
saveUninitialized: false, // don't create session until something stored
|
||||
resave: false, //don't save session if unmodified
|
||||
store,
|
||||
cookie: cookieOptions
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN')
|
||||
return res.status(400).send('Invalid CSRF token!')
|
||||
@@ -136,6 +52,30 @@ const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
}
|
||||
|
||||
export default setProcessVariables().then(async () => {
|
||||
app.use(cookieParser())
|
||||
|
||||
configureLogger(app)
|
||||
|
||||
/***********************************
|
||||
* Handle security and origin *
|
||||
***********************************/
|
||||
configureSecurity(app)
|
||||
|
||||
/***********************************
|
||||
* Enabling CORS *
|
||||
***********************************/
|
||||
configureCors(app)
|
||||
|
||||
/***********************************
|
||||
* DB Connection & *
|
||||
* Express Sessions *
|
||||
* With Mongo Store *
|
||||
***********************************/
|
||||
configureExpressSession(app)
|
||||
|
||||
app.use(express.json({ limit: '100mb' }))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
await setupFolders()
|
||||
await copySASjsCore()
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ const verifyAuthCode = async (
|
||||
clientId: string,
|
||||
code: string
|
||||
): Promise<InfoJWT | undefined> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||
if (err) return resolve(undefined)
|
||||
|
||||
const clientInfo: InfoJWT = {
|
||||
|
||||
@@ -6,15 +6,21 @@ import {
|
||||
getPreProgramVariables,
|
||||
getUserAutoExec,
|
||||
ModeType,
|
||||
parseLogToArray
|
||||
parseLogToArray,
|
||||
RunTimeType
|
||||
} from '../utils'
|
||||
|
||||
interface ExecuteSASCodePayload {
|
||||
interface ExecuteCodePayload {
|
||||
/**
|
||||
* Code of SAS program
|
||||
* @example "* SAS Code HERE;"
|
||||
* Code of program
|
||||
* @example "* Code HERE;"
|
||||
*/
|
||||
code: string
|
||||
/**
|
||||
* runtime for program
|
||||
* @example "js"
|
||||
*/
|
||||
runTime: RunTimeType
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@@ -26,17 +32,17 @@ export class CodeController {
|
||||
* @summary Run SAS Code and returns log
|
||||
*/
|
||||
@Post('/execute')
|
||||
public async executeSASCode(
|
||||
public async executeCode(
|
||||
@Request() request: express.Request,
|
||||
@Body() body: ExecuteSASCodePayload
|
||||
@Body() body: ExecuteCodePayload
|
||||
): Promise<ExecuteReturnJsonResponse> {
|
||||
return executeSASCode(request, body)
|
||||
return executeCode(request, body)
|
||||
}
|
||||
}
|
||||
|
||||
const executeSASCode = async (
|
||||
const executeCode = async (
|
||||
req: express.Request,
|
||||
{ code }: ExecuteSASCodePayload
|
||||
{ code, runTime }: ExecuteCodePayload
|
||||
) => {
|
||||
const { user } = req
|
||||
const userAutoExec =
|
||||
@@ -46,13 +52,14 @@ const executeSASCode = async (
|
||||
|
||||
try {
|
||||
const { webout, log, httpHeaders } =
|
||||
(await new ExecutionController().executeProgram(
|
||||
code,
|
||||
getPreProgramVariables(req),
|
||||
{ ...req.query, _debug: 131 },
|
||||
{ userAutoExec },
|
||||
true
|
||||
)) as ExecuteReturnJson
|
||||
(await new ExecutionController().executeProgram({
|
||||
program: code,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
returnJson: true,
|
||||
runTime: runTime
|
||||
})) as ExecuteReturnJson
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface GroupResponse {
|
||||
description: string
|
||||
}
|
||||
|
||||
interface GroupDetailsResponse {
|
||||
export interface GroupDetailsResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
@@ -198,7 +198,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
'groupId name description isActive users -_id'
|
||||
).populate(
|
||||
'users',
|
||||
'id username displayName -_id'
|
||||
'id username displayName isAdmin -_id'
|
||||
)) as unknown as GroupDetailsResponse
|
||||
if (!group)
|
||||
throw {
|
||||
@@ -249,9 +249,10 @@ const updateUsersListInGroup = async (
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
const updatedGroup = (action === 'addUser'
|
||||
? await group.addUser(user._id)
|
||||
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
|
||||
const updatedGroup =
|
||||
action === 'addUser'
|
||||
? await group.addUser(user)
|
||||
: await group.removeUser(user)
|
||||
|
||||
if (!updatedGroup)
|
||||
throw {
|
||||
@@ -260,9 +261,6 @@ const updateUsersListInGroup = async (
|
||||
message: 'Unable to update group.'
|
||||
}
|
||||
|
||||
if (action === 'addUser') user.addGroup(group._id)
|
||||
else user.removeGroup(group._id)
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
name: updatedGroup.name,
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './code'
|
||||
export * from './drive'
|
||||
export * from './group'
|
||||
export * from './info'
|
||||
export * from './permission'
|
||||
export * from './session'
|
||||
export * from './stp'
|
||||
export * from './user'
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Route, Tags, Example, Get } from 'tsoa'
|
||||
import { getAuthorizedRoutes } from '../utils'
|
||||
export interface AuthorizedRoutesResponse {
|
||||
URIs: string[]
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
mode: string
|
||||
cors: string
|
||||
whiteList: string[]
|
||||
protocol: string
|
||||
runTimes: string[]
|
||||
}
|
||||
|
||||
@Route('SASjsApi/info')
|
||||
@@ -18,7 +23,8 @@ export class InfoController {
|
||||
mode: 'desktop',
|
||||
cors: 'enable',
|
||||
whiteList: ['http://example.com', 'http://example2.com'],
|
||||
protocol: 'http'
|
||||
protocol: 'http',
|
||||
runTimes: ['sas', 'js']
|
||||
})
|
||||
@Get('/')
|
||||
public info(): InfoResponse {
|
||||
@@ -29,7 +35,23 @@ export class InfoController {
|
||||
(process.env.MODE === 'server' ? 'disable' : 'enable'),
|
||||
whiteList:
|
||||
process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [],
|
||||
protocol: process.env.PROTOCOL ?? 'http'
|
||||
protocol: process.env.PROTOCOL ?? 'http',
|
||||
runTimes: process.runTimes
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get authorized routes.
|
||||
*
|
||||
*/
|
||||
@Example<AuthorizedRoutesResponse>({
|
||||
URIs: ['/AppStream', '/SASjsApi/stp/execute']
|
||||
})
|
||||
@Get('/authorizedRoutes')
|
||||
public authorizedRoutes(): AuthorizedRoutesResponse {
|
||||
const response = {
|
||||
URIs: getAuthorizedRoutes()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController } from './'
|
||||
import {
|
||||
readFile,
|
||||
fileExists,
|
||||
createFile,
|
||||
moveFile,
|
||||
readFileBinary
|
||||
} from '@sasjs/utils'
|
||||
import { getSessionController, processProgram } from './'
|
||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||
import {
|
||||
extractHeaders,
|
||||
generateFileUploadSasCode,
|
||||
getFilesFolder,
|
||||
getMacrosFolder,
|
||||
HTTPHeaders,
|
||||
isDebugOn
|
||||
isDebugOn,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
|
||||
export interface ExecutionVars {
|
||||
@@ -33,39 +26,53 @@ export interface ExecuteReturnJson {
|
||||
log?: string
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
async executeFile(
|
||||
programPath: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
otherArgs?: any,
|
||||
returnJson?: boolean,
|
||||
session?: Session
|
||||
) {
|
||||
if (!(await fileExists(programPath)))
|
||||
throw `The Stored Program at (${vars._program}) does not exist, or you do not have permission to view it.`
|
||||
interface ExecuteFileParams {
|
||||
programPath: string
|
||||
preProgramVariables: PreProgramVars
|
||||
vars: ExecutionVars
|
||||
otherArgs?: any
|
||||
returnJson?: boolean
|
||||
session?: Session
|
||||
runTime: RunTimeType
|
||||
}
|
||||
|
||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||
program: string
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
async executeFile({
|
||||
programPath,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session,
|
||||
runTime
|
||||
}: ExecuteFileParams) {
|
||||
const program = await readFile(programPath)
|
||||
|
||||
return this.executeProgram(
|
||||
return this.executeProgram({
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session
|
||||
)
|
||||
session,
|
||||
runTime
|
||||
})
|
||||
}
|
||||
|
||||
async executeProgram(
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
otherArgs?: any,
|
||||
returnJson?: boolean,
|
||||
sessionByFileUpload?: Session
|
||||
): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
|
||||
const sessionController = getSessionController()
|
||||
async executeProgram({
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session: sessionByFileUpload,
|
||||
runTime
|
||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
const session =
|
||||
sessionByFileUpload ?? (await sessionController.getSession())
|
||||
@@ -83,78 +90,18 @@ export class ExecutionController {
|
||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||
)
|
||||
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) =>
|
||||
`${computed}%let ${key}=${vars[key]};\n`,
|
||||
''
|
||||
await processProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
tokenFile,
|
||||
runTime,
|
||||
logPath,
|
||||
otherArgs
|
||||
)
|
||||
|
||||
const preProgramVarStatments = `
|
||||
%let _sasjs_tokenfile=${tokenFile};
|
||||
%let _sasjs_username=${preProgramVariables?.username};
|
||||
%let _sasjs_userid=${preProgramVariables?.userId};
|
||||
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
||||
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
||||
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
||||
%let _metaperson=&_sasjs_displayname;
|
||||
%let _metauser=&_sasjs_username;
|
||||
%let sasjsprocessmode=Stored Program;
|
||||
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
|
||||
|
||||
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
|
||||
%macro _sasjs_server_init();
|
||||
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
|
||||
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
|
||||
%mend;
|
||||
%_sasjs_server_init()
|
||||
`
|
||||
|
||||
program = `
|
||||
options insert=(SASAUTOS="${getMacrosFolder()}");
|
||||
|
||||
/* runtime vars */
|
||||
${varStatments}
|
||||
filename _webout "${weboutPath}" mod;
|
||||
|
||||
/* dynamic user-provided vars */
|
||||
${preProgramVarStatments}
|
||||
|
||||
/* user autoexec starts */
|
||||
${otherArgs?.userAutoExec ?? ''}
|
||||
/* user autoexec ends */
|
||||
|
||||
/* actual job code */
|
||||
${program}`
|
||||
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadSasCode = await generateFileUploadSasCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
//If sas code for the file is generated it will be appended to the top of sasCode
|
||||
if (uploadSasCode.length > 0) {
|
||||
program = `${uploadSasCode}` + program
|
||||
}
|
||||
}
|
||||
|
||||
const codePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// Creating this file in a RUNNING session will break out
|
||||
// the autoexec loop and actually execute the program
|
||||
// but - given it will take several milliseconds to create
|
||||
// (which can mean SAS trying to run a partial program, or
|
||||
// failing due to file lock) we first create the file THEN
|
||||
// we rename it.
|
||||
await createFile(codePath + '.bkp', program)
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (!session.completed) {
|
||||
await delay(50)
|
||||
}
|
||||
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
const headersContent = (await fileExists(headersPath))
|
||||
? await readFile(headersPath)
|
||||
@@ -228,5 +175,3 @@ ${program}`
|
||||
return root
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -2,12 +2,17 @@ import { Request, RequestHandler } from 'express'
|
||||
import multer from 'multer'
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSessionController } from '.'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
getRunTimeAndFilePath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
|
||||
export class FileUploadController {
|
||||
private storage = multer.diskStorage({
|
||||
destination: function (req: Request, file: any, cb: any) {
|
||||
//Sending the intercepted files to the sessions subfolder
|
||||
cb(null, req.sasSession?.path)
|
||||
cb(null, req.sasjsSession?.path)
|
||||
},
|
||||
filename: function (req: Request, file: any, cb: any) {
|
||||
//req_file prefix + unique hash added to sas request files
|
||||
@@ -20,15 +25,42 @@ export class FileUploadController {
|
||||
//It will intercept request and generate unique uuid to be used as a subfolder name
|
||||
//that will store the files uploaded
|
||||
public preUploadMiddleware: RequestHandler = async (req, res, next) => {
|
||||
let session
|
||||
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
|
||||
const { error: errB, value: body } = executeProgramRawValidation(req.body)
|
||||
|
||||
const sessionController = getSessionController()
|
||||
session = await sessionController.getSession()
|
||||
if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||
|
||||
const programPath = (query?._program ?? body?._program) as string
|
||||
|
||||
let runTime
|
||||
|
||||
try {
|
||||
;({ runTime } = await getRunTimeAndFilePath(programPath))
|
||||
} catch (err: any) {
|
||||
return res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
})
|
||||
}
|
||||
|
||||
let sessionController
|
||||
try {
|
||||
sessionController = getSessionController(runTime)
|
||||
} catch (err: any) {
|
||||
return res.status(400).send({
|
||||
status: 'failure',
|
||||
message: err.message,
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
})
|
||||
}
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
// marking consumed true, so that it's not available
|
||||
// as readySession for any other request
|
||||
session.consumed = true
|
||||
|
||||
req.sasSession = session
|
||||
req.sasjsSession = session
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -5,24 +5,28 @@ import { execFile } from 'child_process'
|
||||
import {
|
||||
getSessionsFolder,
|
||||
generateUniqueFileName,
|
||||
sysInitCompiledPath
|
||||
sysInitCompiledPath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
import {
|
||||
deleteFolder,
|
||||
createFile,
|
||||
fileExists,
|
||||
generateTimestamp,
|
||||
readFile
|
||||
readFile,
|
||||
isWindows
|
||||
} from '@sasjs/utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
|
||||
export class SessionController {
|
||||
private sessions: Session[] = []
|
||||
abstract class SessionController {
|
||||
protected sessions: Session[] = []
|
||||
|
||||
private getReadySessions = (): Session[] =>
|
||||
protected getReadySessions = (): Session[] =>
|
||||
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||
|
||||
protected abstract createSession(): Promise<Session>
|
||||
|
||||
public async getSession() {
|
||||
const readySessions = this.getReadySessions()
|
||||
|
||||
@@ -34,8 +38,10 @@ export class SessionController {
|
||||
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
private async createSession(): Promise<Session> {
|
||||
export class SASSessionController extends SessionController {
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
|
||||
@@ -82,7 +88,9 @@ ${autoExecContent}`
|
||||
// however we also need a promise so that we can update the
|
||||
// session array to say that it has (eventually) finished.
|
||||
|
||||
execFilePromise(process.sasLoc, [
|
||||
// Additional windows specific options to avoid the desktop popups.
|
||||
|
||||
execFilePromise(process.sasLoc!, [
|
||||
'-SYSIN',
|
||||
codePath,
|
||||
'-LOG',
|
||||
@@ -93,9 +101,9 @@ ${autoExecContent}`
|
||||
session.path,
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
'-ENCODING',
|
||||
'UTF-8',
|
||||
process.platform === 'win32' ? '-nosplash' : ''
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||
isWindows() ? '-nologo' : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
@@ -154,12 +162,66 @@ ${autoExecContent}`
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionController = (): SessionController => {
|
||||
if (process.sessionController) return process.sessionController
|
||||
export class JSSessionController extends SessionController {
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
|
||||
process.sessionController = new SessionController()
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
// death time of session is 15 mins from creation
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
return process.sessionController
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
await createFile(headersPath, 'Content-type: application/json')
|
||||
|
||||
this.sessions.push(session)
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionController = (
|
||||
runTime: RunTimeType
|
||||
): SASSessionController | JSSessionController => {
|
||||
if (runTime === RunTimeType.SAS) {
|
||||
return getSASSessionController()
|
||||
}
|
||||
|
||||
if (runTime === RunTimeType.JS) {
|
||||
return getJSSessionController()
|
||||
}
|
||||
|
||||
throw new Error('No Runtime is configured')
|
||||
}
|
||||
|
||||
const getSASSessionController = (): SASSessionController => {
|
||||
if (process.sasSessionController) return process.sasSessionController
|
||||
|
||||
process.sasSessionController = new SASSessionController()
|
||||
|
||||
return process.sasSessionController
|
||||
}
|
||||
|
||||
const getJSSessionController = (): JSSessionController => {
|
||||
if (process.jsSessionController) return process.jsSessionController
|
||||
|
||||
process.jsSessionController = new JSSessionController()
|
||||
|
||||
return process.jsSessionController
|
||||
}
|
||||
|
||||
const autoExecContent = `
|
||||
|
||||
69
api/src/controllers/internal/createJSProgram.ts
Normal file
69
api/src/controllers/internal/createJSProgram.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { isWindows } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { generateFileUploadJSCode } from '../../utils'
|
||||
import { ExecutionVars } from './'
|
||||
|
||||
export const createJSProgram = async (
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) =>
|
||||
`${computed}const ${key} = '${vars[key]}';\n`,
|
||||
''
|
||||
)
|
||||
|
||||
const preProgramVarStatments = `
|
||||
let _webout = '';
|
||||
const weboutPath = '${
|
||||
isWindows() ? weboutPath.replace(/\\/g, '\\\\') : weboutPath
|
||||
}';
|
||||
const _sasjs_tokenfile = '${
|
||||
isWindows() ? tokenFile.replace(/\\/g, '\\\\') : tokenFile
|
||||
}';
|
||||
const _sasjs_username = '${preProgramVariables?.username}';
|
||||
const _sasjs_userid = '${preProgramVariables?.userId}';
|
||||
const _sasjs_displayname = '${preProgramVariables?.displayName}';
|
||||
const _metaperson = _sasjs_displayname;
|
||||
const _metauser = _sasjs_username;
|
||||
const sasjsprocessmode = 'Stored Program';
|
||||
`
|
||||
|
||||
const requiredModules = `const fs = require('fs')`
|
||||
|
||||
program = `
|
||||
/* runtime vars */
|
||||
${varStatments}
|
||||
|
||||
/* dynamic user-provided vars */
|
||||
${preProgramVarStatments}
|
||||
|
||||
/* actual job code */
|
||||
${program}
|
||||
|
||||
/* write webout file only if webout exists*/
|
||||
if (_webout) {
|
||||
fs.writeFile(weboutPath, _webout, function (err) {
|
||||
if (err) throw err;
|
||||
})
|
||||
}
|
||||
`
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadJSCode = await generateFileUploadJSCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
//If js code for the file is generated it will be appended to the top of jsCode
|
||||
if (uploadJSCode.length > 0) {
|
||||
program = `${uploadJSCode}\n` + program
|
||||
}
|
||||
}
|
||||
return requiredModules + program
|
||||
}
|
||||
69
api/src/controllers/internal/createSASProgram.ts
Normal file
69
api/src/controllers/internal/createSASProgram.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { generateFileUploadSasCode, getMacrosFolder } from '../../utils'
|
||||
import { ExecutionVars } from './'
|
||||
|
||||
export const createSASProgram = async (
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
tokenFile: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) => `${computed}%let ${key}=${vars[key]};\n`,
|
||||
''
|
||||
)
|
||||
|
||||
const preProgramVarStatments = `
|
||||
%let _sasjs_tokenfile=${tokenFile};
|
||||
%let _sasjs_username=${preProgramVariables?.username};
|
||||
%let _sasjs_userid=${preProgramVariables?.userId};
|
||||
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
||||
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
||||
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
||||
%let _metaperson=&_sasjs_displayname;
|
||||
%let _metauser=&_sasjs_username;
|
||||
%let sasjsprocessmode=Stored Program;
|
||||
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
|
||||
|
||||
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
|
||||
%macro _sasjs_server_init();
|
||||
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
|
||||
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
|
||||
%mend;
|
||||
%_sasjs_server_init()
|
||||
`
|
||||
|
||||
program = `
|
||||
options insert=(SASAUTOS="${getMacrosFolder()}");
|
||||
|
||||
/* runtime vars */
|
||||
${varStatments}
|
||||
filename _webout "${weboutPath}" mod;
|
||||
|
||||
/* dynamic user-provided vars */
|
||||
${preProgramVarStatments}
|
||||
|
||||
/* user autoexec starts */
|
||||
${otherArgs?.userAutoExec ?? ''}
|
||||
/* user autoexec ends */
|
||||
|
||||
/* actual job code */
|
||||
${program}`
|
||||
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadSasCode = await generateFileUploadSasCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
//If sas code for the file is generated it will be appended to the top of sasCode
|
||||
if (uploadSasCode.length > 0) {
|
||||
program = `${uploadSasCode}` + program
|
||||
}
|
||||
}
|
||||
return program
|
||||
}
|
||||
@@ -2,3 +2,6 @@ export * from './deploy'
|
||||
export * from './Session'
|
||||
export * from './Execution'
|
||||
export * from './FileUploadController'
|
||||
export * from './createSASProgram'
|
||||
export * from './createJSProgram'
|
||||
export * from './processProgram'
|
||||
|
||||
86
api/src/controllers/internal/processProgram.ts
Normal file
86
api/src/controllers/internal/processProgram.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { once } from 'stream'
|
||||
import { createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import { ExecutionVars, createSASProgram, createJSProgram } from './'
|
||||
|
||||
export const processProgram = async (
|
||||
program: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: ExecutionVars,
|
||||
session: Session,
|
||||
weboutPath: string,
|
||||
tokenFile: string,
|
||||
runTime: RunTimeType,
|
||||
logPath: string,
|
||||
otherArgs?: any
|
||||
) => {
|
||||
if (runTime === RunTimeType.JS) {
|
||||
program = await createJSProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
|
||||
const codePath = path.join(session.path, 'code.js')
|
||||
|
||||
try {
|
||||
await createFile(codePath, program)
|
||||
|
||||
// create a stream that will write to console outputs to log file
|
||||
const writeStream = fs.createWriteStream(logPath)
|
||||
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
|
||||
execFileSync(process.nodeLoc!, [codePath], {
|
||||
stdio: ['ignore', writeStream, writeStream]
|
||||
})
|
||||
|
||||
// copy the code.js program to log and end write stream
|
||||
writeStream.end(program)
|
||||
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
} catch (err: any) {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
}
|
||||
} else {
|
||||
program = await createSASProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
tokenFile,
|
||||
otherArgs
|
||||
)
|
||||
|
||||
const codePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// Creating this file in a RUNNING session will break out
|
||||
// the autoexec loop and actually execute the program
|
||||
// but - given it will take several milliseconds to create
|
||||
// (which can mean SAS trying to run a partial program, or
|
||||
// failing due to file lock) we first create the file THEN
|
||||
// we rename it.
|
||||
await createFile(codePath + '.bkp', program)
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (!session.completed) {
|
||||
await delay(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
331
api/src/controllers/permission.ts
Normal file
331
api/src/controllers/permission.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Example,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body
|
||||
} from 'tsoa'
|
||||
|
||||
import Permission from '../model/Permission'
|
||||
import User from '../model/User'
|
||||
import Group from '../model/Group'
|
||||
import { UserResponse } from './user'
|
||||
import { GroupDetailsResponse } from './group'
|
||||
|
||||
export enum PrincipalType {
|
||||
user = 'user',
|
||||
group = 'group'
|
||||
}
|
||||
|
||||
export enum PermissionSetting {
|
||||
grant = 'Grant',
|
||||
deny = 'Deny'
|
||||
}
|
||||
|
||||
interface RegisterPermissionPayload {
|
||||
/**
|
||||
* Name of affected resource
|
||||
* @example "/SASjsApi/code/execute"
|
||||
*/
|
||||
uri: string
|
||||
/**
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSetting
|
||||
/**
|
||||
* Indicates the type of principal
|
||||
* @example "user"
|
||||
*/
|
||||
principalType: PrincipalType
|
||||
/**
|
||||
* The id of user or group to which a rule is assigned.
|
||||
* @example 123
|
||||
*/
|
||||
principalId: number
|
||||
}
|
||||
|
||||
interface UpdatePermissionPayload {
|
||||
/**
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSetting
|
||||
}
|
||||
|
||||
export interface PermissionDetailsResponse {
|
||||
permissionId: number
|
||||
uri: string
|
||||
setting: string
|
||||
user?: UserResponse
|
||||
group?: GroupDetailsResponse
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/permission')
|
||||
@Tags('Permission')
|
||||
export class PermissionController {
|
||||
/**
|
||||
* @summary Get list of all permissions (uri, setting and userDetail).
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse[]>([
|
||||
{
|
||||
permissionId: 123,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
permissionId: 124,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: 'Grant',
|
||||
group: {
|
||||
groupId: 1,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllPermissions(): Promise<PermissionDetailsResponse[]> {
|
||||
return getAllPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create a new permission. Admin only.
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Post('/')
|
||||
public async createPermission(
|
||||
@Body() body: RegisterPermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return createPermission(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update permission setting. Admin only
|
||||
* @param permissionId The permission's identifier
|
||||
* @example permissionId 1234
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Patch('{permissionId}')
|
||||
public async updatePermission(
|
||||
@Path() permissionId: number,
|
||||
@Body() body: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return updatePermission(permissionId, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a permission. Admin only.
|
||||
* @param permissionId The user's identifier
|
||||
* @example permissionId 1234
|
||||
*/
|
||||
@Delete('{permissionId}')
|
||||
public async deletePermission(@Path() permissionId: number) {
|
||||
return deletePermission(permissionId)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllPermissions = async (): Promise<PermissionDetailsResponse[]> =>
|
||||
(await Permission.find({})
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
uri: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id',
|
||||
populate: {
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}
|
||||
})) as unknown as PermissionDetailsResponse[]
|
||||
|
||||
const createPermission = async ({
|
||||
uri,
|
||||
setting,
|
||||
principalType,
|
||||
principalId
|
||||
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
|
||||
const permission = new Permission({
|
||||
uri,
|
||||
setting
|
||||
})
|
||||
|
||||
let user: UserResponse | undefined
|
||||
let group: GroupDetailsResponse | undefined
|
||||
|
||||
switch (principalType) {
|
||||
case PrincipalType.user: {
|
||||
const userInDB = await User.findOne({ id: principalId })
|
||||
if (!userInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
if (userInDB.isAdmin)
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: 'Can not add permission for admin user.'
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
uri,
|
||||
user: userInDB._id
|
||||
})
|
||||
|
||||
if (alreadyExists)
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Permission already exists with provided URI and User.'
|
||||
}
|
||||
|
||||
permission.user = userInDB._id
|
||||
|
||||
user = {
|
||||
id: userInDB.id,
|
||||
username: userInDB.username,
|
||||
displayName: userInDB.displayName,
|
||||
isAdmin: userInDB.isAdmin
|
||||
}
|
||||
break
|
||||
}
|
||||
case PrincipalType.group: {
|
||||
const groupInDB = await Group.findOne({ groupId: principalId })
|
||||
if (!groupInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Group not found.'
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
uri,
|
||||
group: groupInDB._id
|
||||
})
|
||||
if (alreadyExists)
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Permission already exists with provided URI and Group.'
|
||||
}
|
||||
|
||||
permission.group = groupInDB._id
|
||||
|
||||
group = {
|
||||
groupId: groupInDB.groupId,
|
||||
name: groupInDB.name,
|
||||
description: groupInDB.description,
|
||||
isActive: groupInDB.isActive,
|
||||
users: groupInDB.populate({
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}) as unknown as UserResponse[]
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'Bad Request',
|
||||
message: 'Invalid principal type. Valid types are user or group.'
|
||||
}
|
||||
}
|
||||
|
||||
const savedPermission = await permission.save()
|
||||
|
||||
return {
|
||||
permissionId: savedPermission.permissionId,
|
||||
uri: savedPermission.uri,
|
||||
setting: savedPermission.setting,
|
||||
user,
|
||||
group
|
||||
}
|
||||
}
|
||||
|
||||
const updatePermission = async (
|
||||
id: number,
|
||||
data: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> => {
|
||||
const { setting } = data
|
||||
|
||||
const updatedPermission = (await Permission.findOneAndUpdate(
|
||||
{ permissionId: id },
|
||||
{ setting },
|
||||
{ new: true }
|
||||
)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
uri: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id'
|
||||
})) as unknown as PermissionDetailsResponse
|
||||
if (!updatedPermission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
|
||||
return updatedPermission
|
||||
}
|
||||
|
||||
const deletePermission = async (id: number) => {
|
||||
const permission = await Permission.findOne({ permissionId: id })
|
||||
if (!permission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
await Permission.deleteOne({ permissionId: id })
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export class SessionController {
|
||||
@Example<UserResponse>({
|
||||
id: 123,
|
||||
username: 'johnusername',
|
||||
displayName: 'John'
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
})
|
||||
@Get('/')
|
||||
public async session(
|
||||
@@ -26,5 +27,6 @@ export class SessionController {
|
||||
const session = (req: express.Request) => ({
|
||||
id: req.user!.userId,
|
||||
username: req.user!.username,
|
||||
displayName: req.user!.displayName
|
||||
displayName: req.user!.displayName,
|
||||
isAdmin: req.user!.isAdmin
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import {
|
||||
Request,
|
||||
Security,
|
||||
@@ -19,12 +18,12 @@ import {
|
||||
} from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getFilesFolder,
|
||||
HTTPHeaders,
|
||||
isDebugOn,
|
||||
LogLine,
|
||||
makeFilesNamesMap,
|
||||
parseLogToArray
|
||||
parseLogToArray,
|
||||
getRunTimeAndFilePath
|
||||
} from '../utils'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
|
||||
@@ -52,26 +51,15 @@ export interface ExecuteReturnJsonResponse {
|
||||
@Tags('STP')
|
||||
export class STPController {
|
||||
/**
|
||||
* Trigger a SAS program using it's location in the _program URL parameter.
|
||||
* Enable debugging using the _debug URL parameter. Setting _debug=131 will
|
||||
* cause the log to be streamed in the output.
|
||||
* Trigger a SAS or JS program using the _program URL parameter.
|
||||
*
|
||||
* Additional URL parameters are turned into SAS macro variables.
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
*
|
||||
* Any files provided in the request body are placed into the SAS session with
|
||||
* corresponding _WEBIN_XXX variables created.
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* The response headers can be adjusted using the mfs_httpheader() macro. Any
|
||||
* file type can be returned, including binary files such as zip or xls.
|
||||
*
|
||||
* If _debug is >= 131, response headers will contain Content-Type: 'text/plain'
|
||||
*
|
||||
* This behaviour differs for POST requests, in which case the response is
|
||||
* always JSON.
|
||||
*
|
||||
* @summary Execute Stored Program, return raw _webout content.
|
||||
* @param _program Location of SAS program
|
||||
* @example _program "/Public/somefolder/some.file"
|
||||
* @summary Execute a Stored Program, returns raw _webout content.
|
||||
* @param _program Location of SAS or JS code
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
*/
|
||||
@Get('/execute')
|
||||
public async executeReturnRaw(
|
||||
@@ -82,29 +70,22 @@ export class STPController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a SAS program using it's location in the _program URL parameter.
|
||||
* Enable debugging using the _debug URL parameter. In any case, the log is
|
||||
* always returned in the log object.
|
||||
* Trigger a SAS or JS program using the _program URL parameter.
|
||||
*
|
||||
* Additional URL parameters are turned into SAS macro variables.
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
*
|
||||
* Any files provided in the request body are placed into the SAS session with
|
||||
* corresponding _WEBIN_XXX variables created.
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* The response will be a JSON object with the following root attributes: log,
|
||||
* webout, headers.
|
||||
* The response will be a JSON object with the following root attributes:
|
||||
* log, webout, headers.
|
||||
*
|
||||
* The webout will be a nested JSON object ONLY if the response-header
|
||||
* The webout attribute will be nested JSON ONLY if the response-header
|
||||
* contains a content-type of application/json AND it is valid JSON.
|
||||
* Otherwise it will be a stringified version of the webout content.
|
||||
*
|
||||
* Response headers from the mfs_httpheader macro are simply listed in the
|
||||
* headers object, for POST requests they have no effect on the actual
|
||||
* response header.
|
||||
*
|
||||
* @summary Execute Stored Program, return JSON
|
||||
* @param _program Location of SAS program
|
||||
* @example _program "/Public/somefolder/some.file"
|
||||
* @summary Execute a Stored Program, return a JSON object
|
||||
* @param _program Location of SAS or JS code
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
*/
|
||||
@Example<ExecuteReturnJsonResponse>({
|
||||
status: 'success',
|
||||
@@ -131,18 +112,17 @@ const executeReturnRaw = async (
|
||||
_program: string
|
||||
): Promise<string | Buffer> => {
|
||||
const query = req.query as ExecutionVars
|
||||
const sasCodePath =
|
||||
path
|
||||
.join(getFilesFolder(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
const { result, httpHeaders } =
|
||||
(await new ExecutionController().executeFile(
|
||||
sasCodePath,
|
||||
getPreProgramVariables(req),
|
||||
query
|
||||
)) as ExecuteReturnRaw
|
||||
(await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: query,
|
||||
runTime
|
||||
})) as ExecuteReturnRaw
|
||||
|
||||
// Should over-ride response header for debug
|
||||
// on GET request to see entire log rendering on browser.
|
||||
@@ -171,25 +151,23 @@ const executeReturnJson = async (
|
||||
req: express.Request,
|
||||
_program: string
|
||||
): Promise<ExecuteReturnJsonResponse> => {
|
||||
const sasCodePath =
|
||||
path
|
||||
.join(getFilesFolder(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
const filesNamesMap = req.files?.length
|
||||
? makeFilesNamesMap(req.files as MulterFile[])
|
||||
: null
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
const { webout, log, httpHeaders } =
|
||||
(await new ExecutionController().executeFile(
|
||||
sasCodePath,
|
||||
getPreProgramVariables(req),
|
||||
{ ...req.query, ...req.body },
|
||||
{ filesNamesMap: filesNamesMap },
|
||||
true,
|
||||
req.sasSession
|
||||
)) as ExecuteReturnJson
|
||||
(await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, ...req.body },
|
||||
otherArgs: { filesNamesMap: filesNamesMap },
|
||||
returnJson: true,
|
||||
session: req.sasjsSession,
|
||||
runTime
|
||||
})) as ExecuteReturnJson
|
||||
|
||||
let weboutRes: string | IRecordOfAny = webout
|
||||
if (httpHeaders['content-type']?.toLowerCase() === 'application/json') {
|
||||
|
||||
@@ -24,9 +24,10 @@ export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
interface UserDetailsResponse {
|
||||
export interface UserDetailsResponse {
|
||||
id: number
|
||||
displayName: string
|
||||
username: string
|
||||
@@ -48,12 +49,14 @@ export class UserController {
|
||||
{
|
||||
id: 123,
|
||||
username: 'johnusername',
|
||||
displayName: 'John'
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 456,
|
||||
username: 'starkusername',
|
||||
displayName: 'Stark'
|
||||
displayName: 'Stark',
|
||||
isAdmin: true
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
@@ -200,7 +203,7 @@ export class UserController {
|
||||
|
||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||
await User.find({})
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
|
||||
.exec()
|
||||
|
||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
@@ -49,10 +49,10 @@ export class WebController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Accept a valid username/password
|
||||
* @summary Destroy the session stored in cookies
|
||||
*
|
||||
*/
|
||||
@Get('/logout')
|
||||
@Get('/SASLogon/logout')
|
||||
public async logout(@Request() req: express.Request) {
|
||||
return new Promise((resolve) => {
|
||||
req.session.destroy(() => {
|
||||
@@ -99,7 +99,8 @@ const login = async (
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { RequestHandler, Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { csrfProtection } from '../app'
|
||||
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils'
|
||||
import {
|
||||
fetchLatestAutoExec,
|
||||
ModeType,
|
||||
verifyTokenInDB,
|
||||
isAuthorizingRoute
|
||||
} from '../utils'
|
||||
import { desktopUser } from './desktop'
|
||||
import { authorize } from './authorize'
|
||||
|
||||
export const authenticateAccessToken: RequestHandler = async (
|
||||
req,
|
||||
@@ -15,6 +21,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
return next()
|
||||
}
|
||||
|
||||
const nextFunction = isAuthorizingRoute(req)
|
||||
? () => authorize(req, res, next)
|
||||
: next
|
||||
|
||||
// if request is coming from web and has valid session
|
||||
// it can be validated.
|
||||
if (req.session?.loggedIn) {
|
||||
@@ -24,7 +34,7 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
if (user) {
|
||||
if (user.isActive) {
|
||||
req.user = user
|
||||
return csrfProtection(req, res, next)
|
||||
return csrfProtection(req, res, nextFunction)
|
||||
} else return res.sendStatus(401)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +44,8 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.ACCESS_TOKEN_SECRET as string,
|
||||
nextFunction,
|
||||
process.secrets.ACCESS_TOKEN_SECRET,
|
||||
'accessToken'
|
||||
)
|
||||
}
|
||||
@@ -45,7 +55,7 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.REFRESH_TOKEN_SECRET as string,
|
||||
process.secrets.REFRESH_TOKEN_SECRET,
|
||||
'refreshToken'
|
||||
)
|
||||
}
|
||||
@@ -58,7 +68,7 @@ const authenticateToken = (
|
||||
tokenType: 'accessToken' | 'refreshToken'
|
||||
) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') {
|
||||
if (MODE === ModeType.Desktop) {
|
||||
req.user = {
|
||||
userId: 1234,
|
||||
clientId: 'desktopModeClientId',
|
||||
|
||||
36
api/src/middlewares/authorize.ts
Normal file
36
api/src/middlewares/authorize.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import User from '../model/User'
|
||||
import Permission from '../model/Permission'
|
||||
import { PermissionSetting } from '../controllers/permission'
|
||||
import { getUri } from '../utils'
|
||||
|
||||
export const authorize: RequestHandler = async (req, res, next) => {
|
||||
const { user } = req
|
||||
|
||||
if (!user) {
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// no need to check for permissions when user is admin
|
||||
if (user.isAdmin) return next()
|
||||
|
||||
const dbUser = await User.findOne({ id: user.userId })
|
||||
if (!dbUser) return res.sendStatus(401)
|
||||
|
||||
const uri = getUri(req)
|
||||
|
||||
// find permission w.r.t user
|
||||
const permission = await Permission.findOne({ uri, user: dbUser._id })
|
||||
|
||||
if (permission) {
|
||||
if (permission.setting === PermissionSetting.grant) return next()
|
||||
else return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// find permission w.r.t user's groups
|
||||
for (const group of dbUser.groups) {
|
||||
const groupPermission = await Permission.findOne({ uri, group })
|
||||
if (groupPermission?.setting === PermissionSetting.grant) return next()
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './authenticateToken'
|
||||
export * from './desktop'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
export * from './authorize'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import { ModeType } from '../utils'
|
||||
|
||||
export const verifyAdmin: RequestHandler = (req, res, next) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') return next()
|
||||
if (MODE === ModeType.Desktop) return next()
|
||||
|
||||
const { user } = req
|
||||
if (!user?.isAdmin) return res.status(401).send('Admin account required')
|
||||
|
||||
45
api/src/model/Configuration.ts
Normal file
45
api/src/model/Configuration.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export interface ConfigurationType {
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Access Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
ACCESS_TOKEN_SECRET: string
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Refresh Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
REFRESH_TOKEN_SECRET: string
|
||||
/**
|
||||
* SecretOrPrivateKey to sign Auth Code
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
AUTH_CODE_SECRET: string
|
||||
/**
|
||||
* Secret used to sign the session cookie
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
const ConfigurationSchema = new Schema<ConfigurationType>({
|
||||
ACCESS_TOKEN_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
REFRESH_TOKEN_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
AUTH_CODE_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
SESSION_SECRET: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Configuration', ConfigurationSchema)
|
||||
@@ -1,5 +1,6 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
import User from './User'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
|
||||
export interface GroupPayload {
|
||||
@@ -27,8 +28,9 @@ interface IGroupDocument extends GroupPayload, Document {
|
||||
}
|
||||
|
||||
interface IGroup extends IGroupDocument {
|
||||
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
addUser(user: IUser): Promise<GroupDetailsResponse>
|
||||
removeUser(user: IUser): Promise<GroupDetailsResponse>
|
||||
hasUser(user: IUser): boolean
|
||||
}
|
||||
interface IGroupModel extends Model<IGroup> {}
|
||||
|
||||
@@ -70,28 +72,31 @@ groupSchema.pre('remove', async function () {
|
||||
})
|
||||
|
||||
// Instance Methods
|
||||
groupSchema.method(
|
||||
'addUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex === -1) {
|
||||
this.users.push(userObjectId)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
groupSchema.method('addUser', async function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex === -1) {
|
||||
this.users.push(userObjectId)
|
||||
user.addGroup(this._id)
|
||||
}
|
||||
)
|
||||
groupSchema.method(
|
||||
'removeUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex > -1) {
|
||||
this.users.splice(userIdIndex, 1)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
})
|
||||
groupSchema.method('removeUser', async function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex > -1) {
|
||||
this.users.splice(userIdIndex, 1)
|
||||
user.removeGroup(this._id)
|
||||
}
|
||||
)
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
})
|
||||
groupSchema.method('hasUser', function (user: IUser) {
|
||||
const userObjectId = user._id
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
return userIdIndex > -1
|
||||
})
|
||||
|
||||
export const Group: IGroupModel = model<IGroup, IGroupModel>(
|
||||
'Group',
|
||||
|
||||
36
api/src/model/Permission.ts
Normal file
36
api/src/model/Permission.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
|
||||
interface IPermissionDocument extends Document {
|
||||
uri: string
|
||||
setting: string
|
||||
permissionId: number
|
||||
user: Schema.Types.ObjectId
|
||||
group: Schema.Types.ObjectId
|
||||
}
|
||||
|
||||
interface IPermission extends IPermissionDocument {}
|
||||
|
||||
interface IPermissionModel extends Model<IPermission> {}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
uri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
setting: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
|
||||
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||
|
||||
export const Permission: IPermissionModel = model<
|
||||
IPermission,
|
||||
IPermissionModel
|
||||
>('Permission', permissionSchema)
|
||||
|
||||
export default Permission
|
||||
@@ -35,6 +35,7 @@ export interface UserPayload {
|
||||
}
|
||||
|
||||
interface IUserDocument extends UserPayload, Document {
|
||||
_id: Schema.Types.ObjectId
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
@@ -43,7 +44,7 @@ interface IUserDocument extends UserPayload, Document {
|
||||
tokens: [{ [key: string]: string }]
|
||||
}
|
||||
|
||||
interface IUser extends IUserDocument {
|
||||
export interface IUser extends IUserDocument {
|
||||
comparePassword(password: string): boolean
|
||||
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import { runSASValidation } from '../../utils'
|
||||
import { runCodeValidation } from '../../utils'
|
||||
import { CodeController } from '../../controllers/'
|
||||
|
||||
const runRouter = express.Router()
|
||||
@@ -7,11 +7,11 @@ const runRouter = express.Router()
|
||||
const controller = new CodeController()
|
||||
|
||||
runRouter.post('/execute', async (req, res) => {
|
||||
const { error, value: body } = runSASValidation(req.body)
|
||||
const { error, value: body } = runCodeValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeSASCode(req, body)
|
||||
const response = await controller.executeCode(req, body)
|
||||
|
||||
if (response instanceof Buffer) {
|
||||
res.writeHead(200, (req as any).sasHeaders)
|
||||
|
||||
@@ -17,6 +17,7 @@ import groupRouter from './group'
|
||||
import clientRouter from './client'
|
||||
import authRouter from './auth'
|
||||
import sessionRouter from './session'
|
||||
import permissionRouter from './permission'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -35,6 +36,12 @@ router.use('/group', desktopRestrict, groupRouter)
|
||||
router.use('/stp', authenticateAccessToken, stpRouter)
|
||||
router.use('/code', authenticateAccessToken, codeRouter)
|
||||
router.use('/user', desktopRestrict, userRouter)
|
||||
router.use(
|
||||
'/permission',
|
||||
desktopRestrict,
|
||||
authenticateAccessToken,
|
||||
permissionRouter
|
||||
)
|
||||
|
||||
router.use(
|
||||
'/',
|
||||
|
||||
@@ -13,4 +13,14 @@ infoRouter.get('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
infoRouter.get('/authorizedRoutes', async (req, res) => {
|
||||
const controller = new InfoController()
|
||||
try {
|
||||
const response = controller.authorizedRoutes()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default infoRouter
|
||||
|
||||
69
api/src/routes/api/permission.ts
Normal file
69
api/src/routes/api/permission.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import express from 'express'
|
||||
import { PermissionController } from '../../controllers/'
|
||||
import { verifyAdmin } from '../../middlewares'
|
||||
import {
|
||||
registerPermissionValidation,
|
||||
updatePermissionValidation
|
||||
} from '../../utils'
|
||||
|
||||
const permissionRouter = express.Router()
|
||||
const controller = new PermissionController()
|
||||
|
||||
permissionRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await controller.getAllPermissions()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.post('/', verifyAdmin, async (req, res) => {
|
||||
const { error, value: body } = registerPermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.createPermission(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
|
||||
const { error, value: body } = updatePermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.updatePermission(permissionId, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.delete(
|
||||
'/:permissionId',
|
||||
verifyAdmin,
|
||||
async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
|
||||
try {
|
||||
await controller.deletePermission(permissionId)
|
||||
res.status(200).send('Permission Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
export default permissionRouter
|
||||
@@ -29,7 +29,12 @@ jest
|
||||
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
||||
|
||||
import appPromise from '../../../app'
|
||||
import { UserController } from '../../../controllers/'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionSetting,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import { getTreeExample } from '../../../controllers/internal'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
|
||||
const { getFilesFolder } = fileUtilModules
|
||||
@@ -48,6 +53,7 @@ describe('drive', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
const controller = new UserController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
let accessToken: string
|
||||
|
||||
@@ -58,11 +64,31 @@ describe('drive', () => {
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/deploy',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/deploy/upload',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/file',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/folder',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -945,3 +971,12 @@ describe('drive', () => {
|
||||
const getExampleService = (): ServiceMember =>
|
||||
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
|
||||
.members[0] as ServiceMember
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
|
||||
return adminAccessToken
|
||||
}
|
||||
|
||||
571
api/src/routes/api/spec/permission.spec.ts
Normal file
571
api/src/routes/api/spec/permission.spec.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
DriveController,
|
||||
UserController,
|
||||
GroupController,
|
||||
ClientController,
|
||||
PermissionController,
|
||||
PrincipalType,
|
||||
PermissionSetting
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
UserDetailsResponse,
|
||||
PermissionDetailsResponse
|
||||
} from '../../../controllers'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
|
||||
const deployPayload = {
|
||||
appLoc: 'string',
|
||||
streamWebFolder: 'string',
|
||||
fileTree: {
|
||||
members: [
|
||||
{
|
||||
name: 'string',
|
||||
type: 'folder',
|
||||
members: [
|
||||
'string',
|
||||
{
|
||||
name: 'string',
|
||||
type: 'service',
|
||||
code: 'string'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
displayName: 'Test Admin',
|
||||
username: 'testAdminUsername',
|
||||
password: '12345678',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
const user = {
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const permission = {
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: PermissionSetting.grant,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: 123
|
||||
}
|
||||
|
||||
const group = {
|
||||
name: 'DCGroup1',
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
|
||||
const userController = new UserController()
|
||||
const groupController = new GroupController()
|
||||
const clientController = new ClientController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
describe('permission', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let adminAccessToken: string
|
||||
let dbUser: UserDetailsResponse
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appPromise
|
||||
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
dbUser = await userController.createUser(user)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllPermissions()
|
||||
})
|
||||
|
||||
it('should respond with new permission when principalType is user', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uri).toEqual(permission.uri)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
expect(res.body.user).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should respond with new permission when principalType is group', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: dbGroup.groupId
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uri).toEqual(permission.uri)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
expect(res.body.group).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.send(permission)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account even if user has permission', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/permission',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if uri is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
uri: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"uri" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if uri is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
uri: '/some/random/api/endpoint'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"setting" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalType is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"principalType" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"principalId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principal type is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalType" must be one of [user, group]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is not a number', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 'someCharacters'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalId" must be a number')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if adding permission for admin user', async () => {
|
||||
const adminUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'adminUser',
|
||||
isAdmin: true
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: adminUser.id
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Can not add permission for admin user.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Not Found (404) if user is not found', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 123
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('User not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Not Found (404) if group is not found', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group'
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Group not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Conflict (409) if permission already exists', async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
'Permission already exists with provided URI and User.'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
let dbPermission: PermissionDetailsResponse | undefined
|
||||
beforeAll(async () => {
|
||||
dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllPermissions()
|
||||
})
|
||||
|
||||
it('should respond with updated permission', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ setting: 'Deny' })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.setting).toEqual('Deny')
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.send(permission)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'update' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"setting" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/permission/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: PermissionSetting.deny
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Permission not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete permission', async () => {
|
||||
const dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
})
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.text).toEqual('Permission Deleted!')
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/permission/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
|
||||
expect(res.text).toEqual('Permission not found.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/test-1',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/test-2',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
it('should give a list of all permissions when user is admin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/permission/')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should give a list of all permissions when user is not admin', async () => {
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'get' + user.username
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/permission',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/permission/')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe.only('verify', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(DriveController.prototype, 'deploy')
|
||||
.mockImplementation((deployPayload) =>
|
||||
Promise.resolve({
|
||||
status: 'success',
|
||||
message: 'Files deployed successfully to @sasjs/server.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should create files in SASJS drive', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(deployPayload)
|
||||
.expect(200)
|
||||
})
|
||||
|
||||
it('should respond unauthorized', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy/upload')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser?: any
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
|
||||
return adminAccessToken
|
||||
}
|
||||
|
||||
const deleteAllPermissions = async () => {
|
||||
const { collections } = mongoose.connection
|
||||
const collection = collections['permissions']
|
||||
await collection.deleteMany({})
|
||||
}
|
||||
397
api/src/routes/api/spec/stp.spec.ts
Normal file
397
api/src/routes/api/spec/stp.spec.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import path from 'path'
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionSetting,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
getFilesFolder,
|
||||
RunTimeType,
|
||||
generateUniqueFileName,
|
||||
getSessionsFolder
|
||||
} from '../../../utils'
|
||||
import { createFile, generateTimestamp, deleteFolder } from '@sasjs/utils'
|
||||
import {
|
||||
SASSessionController,
|
||||
JSSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
|
||||
const user = {
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const sampleSasProgram = '%put hello world!;'
|
||||
const sampleJsProgram = `console.log('hello world!/')`
|
||||
|
||||
const filesFolder = getFilesFolder()
|
||||
|
||||
describe('stp', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let accessToken: string
|
||||
const userController = new UserController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await appPromise
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/stp/execute',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
|
||||
describe('get', () => {
|
||||
describe('with runtime js', () => {
|
||||
const testFilesFolder = `test-stp-${generateTimestamp()}`
|
||||
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.JS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute js program when both js and sas program are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when js program is not present but sas program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime sas', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.SAS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute sas program when both sas and js programs are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when sas program do not exit but js exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime js and sas', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.JS, RunTimeType.SAS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute js program when both js and sas program are present', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute sas program when js program is not present but sas program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and js programs do not exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with runtime sas and js', () => {
|
||||
beforeAll(() => {
|
||||
process.runTimes = [RunTimeType.SAS, RunTimeType.JS]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules() // it clears the cache
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.resetAllMocks()
|
||||
await deleteFolder(path.join(filesFolder, testFilesFolder))
|
||||
})
|
||||
|
||||
it('should execute sas program when both sas and js programs exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(sasProgramPath, sampleSasProgram)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.SAS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute js program when sas program is not present but js program exists', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
|
||||
await createFile(jsProgramPath, sampleJsProgram)
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
RunTimeType.JS,
|
||||
expect.anything(),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when both sas and js programs do not exist', async () => {
|
||||
const programPath = path.join(testFilesFolder, 'program')
|
||||
|
||||
await request(app)
|
||||
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser: any
|
||||
): Promise<string> => {
|
||||
const userController = new UserController()
|
||||
const dbUser = await userController.createUser(someUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
|
||||
return accessToken
|
||||
}
|
||||
|
||||
const setupMocks = async () => {
|
||||
jest
|
||||
.spyOn(SASSessionController.prototype, 'getSession')
|
||||
.mockImplementation(mockedGetSession)
|
||||
|
||||
jest
|
||||
.spyOn(JSSessionController.prototype, 'getSession')
|
||||
.mockImplementation(mockedGetSession)
|
||||
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
}
|
||||
|
||||
const mockedGetSession = async () => {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
// death time of session is 15 mins from creation
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -770,12 +770,14 @@ describe('user', () => {
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -796,12 +798,14 @@ describe('user', () => {
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: 'randomUser',
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('web', () => {
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,16 +35,17 @@ stpRouter.post(
|
||||
fileUploadController.preUploadMiddleware,
|
||||
fileUploadController.getMulterUploadObject().any(),
|
||||
async (req, res: any) => {
|
||||
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
|
||||
const { error: errB, value: body } = executeProgramRawValidation(req.body)
|
||||
// below validations are moved to preUploadMiddleware
|
||||
// const { error: errQ, value: query } = executeProgramRawValidation(req.query)
|
||||
// const { error: errB, value: body } = executeProgramRawValidation(req.body)
|
||||
|
||||
if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||
// if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeReturnJson(
|
||||
req,
|
||||
body,
|
||||
query?._program
|
||||
req.body,
|
||||
req.query?._program as string
|
||||
)
|
||||
|
||||
// TODO: investigate if this code is required
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import express, { Request } from 'express'
|
||||
import { authenticateAccessToken } from '../../middlewares'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
|
||||
@@ -9,7 +10,7 @@ const appStreams: { [key: string]: string } = {}
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
router.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const content = appStreamHtml(process.appStreamConfig)
|
||||
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken())
|
||||
@@ -66,7 +67,7 @@ export const publishAppStream = async (
|
||||
return {}
|
||||
}
|
||||
|
||||
router.get(`/*`, function (req: Request, res, next) {
|
||||
router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) {
|
||||
const reqPath = req.path.replace(/^\//, '')
|
||||
|
||||
// Redirecting to url with trailing slash for appStream base URL only
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express'
|
||||
import { cookieOptions } from '../../app'
|
||||
import { WebController } from '../../controllers/web'
|
||||
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
|
||||
import { authorizeValidation, loginWebValidation } from '../../utils'
|
||||
@@ -13,7 +14,7 @@ webRouter.get('/', async (req, res) => {
|
||||
} catch (_) {
|
||||
response = 'Web Build is not present'
|
||||
} finally {
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken())
|
||||
res.cookie('XSRF-TOKEN', req.csrfToken(), cookieOptions)
|
||||
|
||||
return res.send(response)
|
||||
}
|
||||
@@ -48,7 +49,7 @@ webRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
webRouter.get('/logout', desktopRestrict, async (req, res) => {
|
||||
webRouter.get('/SASLogon/logout', desktopRestrict, async (req, res) => {
|
||||
try {
|
||||
await controller.logout(req)
|
||||
res.status(200).send('OK!')
|
||||
|
||||
@@ -16,9 +16,9 @@ appPromise.then(async (app) => {
|
||||
)
|
||||
})
|
||||
} else {
|
||||
const { key, cert } = await getCertificates()
|
||||
const { key, cert, ca } = await getCertificates()
|
||||
|
||||
const httpsServer = createServer({ key, cert }, app)
|
||||
const httpsServer = createServer({ key, cert, ca }, app)
|
||||
httpsServer.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
||||
|
||||
2
api/src/types/system/express.d.ts
vendored
2
api/src/types/system/express.d.ts
vendored
@@ -2,6 +2,6 @@ declare namespace Express {
|
||||
export interface Request {
|
||||
accessToken?: string
|
||||
user?: import('../').RequestUser
|
||||
sasSession?: import('../').Session
|
||||
sasjsSession?: import('../').Session
|
||||
}
|
||||
}
|
||||
|
||||
9
api/src/types/system/process.d.ts
vendored
9
api/src/types/system/process.d.ts
vendored
@@ -1,9 +1,14 @@
|
||||
declare namespace NodeJS {
|
||||
export interface Process {
|
||||
sasLoc: string
|
||||
sasLoc?: string
|
||||
nodeLoc?: string
|
||||
driveLoc: string
|
||||
sessionController?: import('../../controllers/internal').SessionController
|
||||
logsLoc: string
|
||||
sasSessionController?: import('../../controllers/internal').SASSessionController
|
||||
jsSessionController?: import('../../controllers/internal').JSSessionController
|
||||
appStreamConfig: import('../').AppStreamConfig
|
||||
logger: import('@sasjs/utils/logger').Logger
|
||||
runTimes: import('../../utils').RunTimeType[]
|
||||
secrets: import('../../model/Configuration').ConfigurationType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppStreamConfig } from '../types'
|
||||
import { getAppStreamConfigPath } from './file'
|
||||
|
||||
export const loadAppStreamConfig = async () => {
|
||||
process.appStreamConfig = {}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') return
|
||||
|
||||
const appStreamConfigPath = getAppStreamConfigPath()
|
||||
@@ -21,7 +23,6 @@ export const loadAppStreamConfig = async () => {
|
||||
} catch (_) {
|
||||
appStreamConfig = {}
|
||||
}
|
||||
process.appStreamConfig = {}
|
||||
|
||||
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
|
||||
const { appLoc, streamWebFolder, streamLogo } = entry
|
||||
|
||||
@@ -9,7 +9,5 @@ export const connectDB = async () => {
|
||||
}
|
||||
|
||||
console.log('Connected to DB!')
|
||||
await seedDB()
|
||||
|
||||
return mongoose.connection
|
||||
return seedDB()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export const getDesktopUserAutoExecPath = () =>
|
||||
|
||||
export const getSasjsRootFolder = () => process.driveLoc
|
||||
|
||||
export const getLogFolder = () => process.logsLoc
|
||||
|
||||
export const getAppStreamConfigPath = () =>
|
||||
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
|
||||
|
||||
@@ -32,8 +34,6 @@ export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
|
||||
|
||||
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
|
||||
|
||||
export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
|
||||
|
||||
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
|
||||
|
||||
export const getSessionsFolder = () =>
|
||||
|
||||
@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateAccessToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
|
||||
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
||||
expiresIn: '1day'
|
||||
})
|
||||
|
||||
@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateAuthCode = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
|
||||
jwt.sign(data, process.secrets.AUTH_CODE_SECRET, {
|
||||
expiresIn: '30s'
|
||||
})
|
||||
|
||||
@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateRefreshToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
|
||||
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
||||
expiresIn: '30 days'
|
||||
})
|
||||
|
||||
35
api/src/utils/getAuthorizedRoutes.ts
Normal file
35
api/src/utils/getAuthorizedRoutes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Request } from 'express'
|
||||
|
||||
const StaticAuthorizedRoutes = [
|
||||
'/AppStream',
|
||||
'/SASjsApi/code/execute',
|
||||
'/SASjsApi/stp/execute',
|
||||
'/SASjsApi/drive/deploy',
|
||||
'/SASjsApi/drive/deploy/upload',
|
||||
'/SASjsApi/drive/file',
|
||||
'/SASjsApi/drive/folder',
|
||||
'/SASjsApi/drive/fileTree',
|
||||
'/SASjsApi/permission'
|
||||
]
|
||||
|
||||
export const getAuthorizedRoutes = () => {
|
||||
const streamingApps = Object.keys(process.appStreamConfig)
|
||||
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
|
||||
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||
}
|
||||
|
||||
export const getUri = (req: Request) => {
|
||||
const { baseUrl, path: reqPath } = req
|
||||
|
||||
if (baseUrl === '/AppStream') {
|
||||
const appStream = reqPath.split('/')[1]
|
||||
|
||||
// removing trailing slash of URLs
|
||||
return (baseUrl + '/' + appStream).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
return (baseUrl + reqPath).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export const isAuthorizingRoute = (req: Request): boolean =>
|
||||
getAuthorizedRoutes().includes(getUri(req))
|
||||
@@ -2,22 +2,32 @@ import path from 'path'
|
||||
import { fileExists, getString, readFile } from '@sasjs/utils'
|
||||
|
||||
export const getCertificates = async () => {
|
||||
const { PRIVATE_KEY, FULL_CHAIN } = process.env
|
||||
const { PRIVATE_KEY, CERT_CHAIN, CA_ROOT } = process.env
|
||||
|
||||
let ca
|
||||
|
||||
const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)'))
|
||||
const certPath = FULL_CHAIN ?? (await getFileInput('Full Chain (PEM)'))
|
||||
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
|
||||
const caPath = CA_ROOT
|
||||
|
||||
console.log('keyPath: ', keyPath)
|
||||
console.log('certPath: ', certPath)
|
||||
if (caPath) console.log('caPath: ', caPath)
|
||||
|
||||
const key = await readFile(keyPath)
|
||||
const cert = await readFile(certPath)
|
||||
if (caPath) ca = await readFile(caPath)
|
||||
|
||||
return { key, cert }
|
||||
return { key, cert, ca }
|
||||
}
|
||||
|
||||
const getFileInput = async (filename: string): Promise<string> => {
|
||||
const getFileInput = async (
|
||||
filename: string,
|
||||
required: boolean = true
|
||||
): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!required) return true
|
||||
|
||||
if (!filePath) return `Path to ${filename} is required.`
|
||||
|
||||
if (!(await fileExists(path.join(process.cwd(), filePath)))) {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import path from 'path'
|
||||
import { getString } from '@sasjs/utils/input'
|
||||
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
|
||||
|
||||
const isWindows = () => process.platform === 'win32'
|
||||
import { createFolder, fileExists, folderExists, isWindows } from '@sasjs/utils'
|
||||
import { RunTimeType } from './verifyEnvVariables'
|
||||
|
||||
export const getDesktopFields = async () => {
|
||||
const { SAS_PATH } = process.env
|
||||
const { SAS_PATH, NODE_PATH } = process.env
|
||||
|
||||
const sasLoc = SAS_PATH ?? (await getSASLocation())
|
||||
// const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
|
||||
let sasLoc, nodeLoc
|
||||
|
||||
return { sasLoc }
|
||||
if (process.runTimes.includes(RunTimeType.SAS)) {
|
||||
sasLoc = SAS_PATH ?? (await getSASLocation())
|
||||
}
|
||||
|
||||
if (process.runTimes.includes(RunTimeType.JS)) {
|
||||
nodeLoc = NODE_PATH ?? (await getNodeLocation())
|
||||
}
|
||||
|
||||
return { sasLoc, nodeLoc }
|
||||
}
|
||||
|
||||
const getDriveLocation = async (): Promise<string> => {
|
||||
@@ -54,7 +60,31 @@ const getSASLocation = async (): Promise<string> => {
|
||||
: '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter path to SAS executable (absolute path): ',
|
||||
'Please enter full path to a SAS executable with UTF-8 encoding: ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
return targetName
|
||||
}
|
||||
|
||||
const getNodeLocation = async (): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!filePath) return 'Path to NodeJS executable is required.'
|
||||
|
||||
if (!(await fileExists(filePath))) {
|
||||
return 'No file found at provided path.'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultLocation = isWindows()
|
||||
? 'C:\\Program Files\\nodejs\\node.exe'
|
||||
: '/usr/local/nodejs/bin/node.sh'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter full path to a NodeJS executable: ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
33
api/src/utils/getRunTimeAndFilePath.ts
Normal file
33
api/src/utils/getRunTimeAndFilePath.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'path'
|
||||
import { fileExists } from '@sasjs/utils'
|
||||
import { getFilesFolder } from './file'
|
||||
import { RunTimeType } from '.'
|
||||
|
||||
export const getRunTimeAndFilePath = async (programPath: string) => {
|
||||
const ext = path.extname(programPath)
|
||||
// If programPath (_program) is provided with a ".sas" or ".js" extension
|
||||
// we should use that extension to determine the appropriate runTime
|
||||
if (ext && Object.values(RunTimeType).includes(ext.slice(1) as RunTimeType)) {
|
||||
const runTime = ext.slice(1)
|
||||
|
||||
const codePath = path
|
||||
.join(getFilesFolder(), programPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (await fileExists(codePath)) {
|
||||
return { codePath, runTime: runTime as RunTimeType }
|
||||
}
|
||||
} else {
|
||||
for (const runTime of process.runTimes) {
|
||||
const codePath =
|
||||
path
|
||||
.join(getFilesFolder(), programPath)
|
||||
.replace(new RegExp('/', 'g'), path.sep) +
|
||||
'.' +
|
||||
runTime
|
||||
|
||||
if (await fileExists(codePath)) return { codePath, runTime }
|
||||
}
|
||||
}
|
||||
throw `The Program at (${programPath}) does not exist.`
|
||||
}
|
||||
@@ -8,9 +8,11 @@ export * from './file'
|
||||
export * from './generateAccessToken'
|
||||
export * from './generateAuthCode'
|
||||
export * from './generateRefreshToken'
|
||||
export * from './getAuthorizedRoutes'
|
||||
export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './getPreProgramVariables'
|
||||
export * from './getRunTimeAndFilePath'
|
||||
export * from './getServerUrl'
|
||||
export * from './instantiateLogger'
|
||||
export * from './isDebugOn'
|
||||
|
||||
@@ -1,6 +1,73 @@
|
||||
import Client from '../model/Client'
|
||||
import Group from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import Configuration, { ConfigurationType } from '../model/Configuration'
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export const SECRETS: ConfigurationType = {
|
||||
ACCESS_TOKEN_SECRET: randomBytes(64).toString('hex'),
|
||||
REFRESH_TOKEN_SECRET: randomBytes(64).toString('hex'),
|
||||
AUTH_CODE_SECRET: randomBytes(64).toString('hex'),
|
||||
SESSION_SECRET: randomBytes(64).toString('hex')
|
||||
}
|
||||
|
||||
export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
// Checking if client is already in the database
|
||||
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
|
||||
if (!clientExist) {
|
||||
const client = new Client(CLIENT)
|
||||
await client.save()
|
||||
|
||||
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
|
||||
}
|
||||
|
||||
// Checking if 'AllUsers' Group is already in the database
|
||||
let groupExist = await Group.findOne({ name: GROUP.name })
|
||||
if (!groupExist) {
|
||||
const group = new Group(GROUP)
|
||||
groupExist = await group.save()
|
||||
|
||||
console.log(`DB Seed - Group created: ${GROUP.name}`)
|
||||
}
|
||||
|
||||
// Checking if user is already in the database
|
||||
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
||||
if (!usernameExist) {
|
||||
const user = new User(ADMIN_USER)
|
||||
usernameExist = await user.save()
|
||||
|
||||
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
|
||||
}
|
||||
|
||||
if (!groupExist.hasUser(usernameExist)) {
|
||||
groupExist.addUser(usernameExist)
|
||||
console.log(
|
||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
|
||||
)
|
||||
}
|
||||
|
||||
// checking if configuration is present in the database
|
||||
let configExist = await Configuration.findOne()
|
||||
if (!configExist) {
|
||||
const configuration = new Configuration(SECRETS)
|
||||
configExist = await configuration.save()
|
||||
|
||||
console.log('DB Seed - configuration added')
|
||||
}
|
||||
|
||||
return {
|
||||
ACCESS_TOKEN_SECRET: configExist.ACCESS_TOKEN_SECRET,
|
||||
REFRESH_TOKEN_SECRET: configExist.REFRESH_TOKEN_SECRET,
|
||||
AUTH_CODE_SECRET: configExist.AUTH_CODE_SECRET,
|
||||
SESSION_SECRET: configExist.SESSION_SECRET
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP = {
|
||||
name: 'AllUsers',
|
||||
description: 'Group contains all users'
|
||||
}
|
||||
const CLIENT = {
|
||||
clientId: 'clientID1',
|
||||
clientSecret: 'clientSecret'
|
||||
@@ -13,23 +80,3 @@ const ADMIN_USER = {
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
export const seedDB = async () => {
|
||||
// Checking if client is already in the database
|
||||
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
|
||||
if (!clientExist) {
|
||||
const client = new Client(CLIENT)
|
||||
await client.save()
|
||||
|
||||
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
|
||||
}
|
||||
|
||||
// Checking if user is already in the database
|
||||
const usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
||||
if (!usernameExist) {
|
||||
const user = new User(ADMIN_USER)
|
||||
await user.save()
|
||||
|
||||
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import path from 'path'
|
||||
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
|
||||
|
||||
import { getDesktopFields, ModeType } from '.'
|
||||
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||
|
||||
export const setProcessVariables = async () => {
|
||||
const { MODE, RUN_TIMES } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
// NOTE: when exporting app.js as agent for supertest
|
||||
// it should prevent connecting to the real database
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const secrets = await connectDB()
|
||||
|
||||
process.secrets = secrets
|
||||
} else {
|
||||
process.secrets = SECRETS
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
|
||||
return
|
||||
}
|
||||
|
||||
const { MODE } = process.env
|
||||
process.runTimes = (RUN_TIMES?.split(',') as RunTimeType[]) ?? []
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
process.sasLoc = process.env.SAS_PATH as string
|
||||
process.sasLoc = process.env.SAS_PATH
|
||||
process.nodeLoc = process.env.NODE_PATH
|
||||
} else {
|
||||
const { sasLoc } = await getDesktopFields()
|
||||
const { sasLoc, nodeLoc } = await getDesktopFields()
|
||||
|
||||
process.sasLoc = sasLoc
|
||||
process.nodeLoc = nodeLoc
|
||||
}
|
||||
|
||||
const { SASJS_ROOT } = process.env
|
||||
@@ -24,6 +40,16 @@ export const setProcessVariables = async () => {
|
||||
await createFolder(absPath)
|
||||
process.driveLoc = getRealPath(absPath)
|
||||
|
||||
const { LOG_LOCATION } = process.env
|
||||
const absLogsPath = getAbsolutePath(
|
||||
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
|
||||
process.cwd()
|
||||
)
|
||||
await createFolder(absLogsPath)
|
||||
process.logsLoc = getRealPath(absLogsPath)
|
||||
|
||||
console.log('sasLoc: ', process.sasLoc)
|
||||
console.log('sasDrive: ', process.driveLoc)
|
||||
console.log('sasLogs: ', process.logsLoc)
|
||||
console.log('runTimes: ', process.runTimes)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { extractHeaders } from '..'
|
||||
import { extractHeaders } from '../extractHeaders'
|
||||
|
||||
describe('extractHeaders', () => {
|
||||
it('should return valid http headers', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseLogToArray } from '..'
|
||||
import { parseLogToArray } from '../parseLogToArray'
|
||||
|
||||
describe('parseLogToArray', () => {
|
||||
it('should parse log to array type', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
import { listFilesInFolder } from '@sasjs/utils'
|
||||
import { listFilesInFolder, readFileBinary, isWindows } from '@sasjs/utils'
|
||||
|
||||
interface FilenameMapSingle {
|
||||
fieldName: string
|
||||
@@ -98,3 +99,36 @@ export const generateFileUploadSasCode = async (
|
||||
|
||||
return uploadSasCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the js code that references uploaded files in the concurrent request
|
||||
* @param filesNamesMap object that maps hashed file names and original file names
|
||||
* @param sessionFolder name of the folder that is created for the purpose of files in concurrent request
|
||||
* @returns generated js code
|
||||
*/
|
||||
export const generateFileUploadJSCode = async (
|
||||
filesNamesMap: FilenamesMap,
|
||||
sessionFolder: string
|
||||
) => {
|
||||
let uploadCode = ''
|
||||
let fileCount = 0
|
||||
|
||||
const sessionFolderList: string[] = await listFilesInFolder(sessionFolder)
|
||||
sessionFolderList.forEach(async (fileName) => {
|
||||
if (fileName.includes('req_file')) {
|
||||
fileCount++
|
||||
const filePath = path.join(sessionFolder, fileName)
|
||||
uploadCode += `\nconst _WEBIN_FILEREF${fileCount} = fs.readFileSync('${
|
||||
isWindows() ? filePath.replace(/\\/g, '\\\\') : filePath
|
||||
}')`
|
||||
uploadCode += `\nconst _WEBIN_FILENAME${fileCount} = '${filesNamesMap[fileName].originalName}'`
|
||||
uploadCode += `\nconst _WEBIN_NAME${fileCount} = '${filesNamesMap[fileName].fieldName}'`
|
||||
}
|
||||
})
|
||||
|
||||
if (fileCount) {
|
||||
uploadCode = `\nconst _WEBIN_FILE_COUNT = ${fileCount}` + uploadCode
|
||||
}
|
||||
|
||||
return uploadCode
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Joi from 'joi'
|
||||
import { PermissionSetting, PrincipalType } from '../controllers/permission'
|
||||
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
|
||||
|
||||
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
|
||||
const passwordSchema = Joi.string().min(6).max(1024)
|
||||
@@ -85,6 +87,27 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||
clientSecret: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
uri: Joi.string()
|
||||
.required()
|
||||
.valid(...getAuthorizedRoutes()),
|
||||
setting: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PermissionSetting)),
|
||||
principalType: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PrincipalType)),
|
||||
principalId: Joi.number().required()
|
||||
}).validate(data)
|
||||
|
||||
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
setting: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PermissionSetting))
|
||||
}).validate(data)
|
||||
|
||||
export const deployValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
appLoc: Joi.string().pattern(/^\//).required().min(2),
|
||||
@@ -120,9 +143,10 @@ export const folderParamValidation = (data: any): Joi.ValidationResult =>
|
||||
_folderPath: Joi.string()
|
||||
}).validate(data)
|
||||
|
||||
export const runSASValidation = (data: any): Joi.ValidationResult =>
|
||||
export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
code: Joi.string().required()
|
||||
code: Joi.string().required(),
|
||||
runTime: Joi.string().valid(...process.runTimes)
|
||||
}).validate(data)
|
||||
|
||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
|
||||
@@ -26,6 +26,11 @@ export enum LOG_FORMAT_MORGANType {
|
||||
tiny = 'tiny'
|
||||
}
|
||||
|
||||
export enum RunTimeType {
|
||||
SAS = 'sas',
|
||||
JS = 'js'
|
||||
}
|
||||
|
||||
export enum ReturnCode {
|
||||
Success,
|
||||
InvalidEnv
|
||||
@@ -46,6 +51,10 @@ export const verifyEnvVariables = (): ReturnCode => {
|
||||
|
||||
errors.push(...verifyLOG_FORMAT_MORGAN())
|
||||
|
||||
errors.push(...verifyRUN_TIMES())
|
||||
|
||||
errors.push(...verifyExecutablePaths())
|
||||
|
||||
if (errors.length) {
|
||||
process.logger?.error(
|
||||
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
||||
@@ -69,33 +78,7 @@ const verifyMODE = (): string[] => {
|
||||
}
|
||||
|
||||
if (process.env.MODE === ModeType.Server) {
|
||||
const {
|
||||
ACCESS_TOKEN_SECRET,
|
||||
REFRESH_TOKEN_SECRET,
|
||||
AUTH_CODE_SECRET,
|
||||
SESSION_SECRET,
|
||||
DB_CONNECT
|
||||
} = process.env
|
||||
|
||||
if (!ACCESS_TOKEN_SECRET)
|
||||
errors.push(
|
||||
`- ACCESS_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
|
||||
)
|
||||
|
||||
if (!REFRESH_TOKEN_SECRET)
|
||||
errors.push(
|
||||
`- REFRESH_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
|
||||
)
|
||||
|
||||
if (!AUTH_CODE_SECRET)
|
||||
errors.push(
|
||||
`- AUTH_CODE_SECRET is required for PROTOCOL '${ModeType.Server}'`
|
||||
)
|
||||
|
||||
if (!SESSION_SECRET)
|
||||
errors.push(
|
||||
`- SESSION_SECRET is required for PROTOCOL '${ModeType.Server}'`
|
||||
)
|
||||
const { DB_CONNECT } = process.env
|
||||
|
||||
if (process.env.NODE_ENV !== 'test')
|
||||
if (!DB_CONNECT)
|
||||
@@ -120,16 +103,16 @@ const verifyPROTOCOL = (): string[] => {
|
||||
}
|
||||
|
||||
if (process.env.PROTOCOL === ProtocolType.HTTPS) {
|
||||
const { PRIVATE_KEY, FULL_CHAIN } = process.env
|
||||
const { PRIVATE_KEY, CERT_CHAIN } = process.env
|
||||
|
||||
if (!PRIVATE_KEY)
|
||||
errors.push(
|
||||
`- PRIVATE_KEY is required for PROTOCOL '${ProtocolType.HTTPS}'`
|
||||
)
|
||||
|
||||
if (!FULL_CHAIN)
|
||||
if (!CERT_CHAIN)
|
||||
errors.push(
|
||||
`- FULL_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
|
||||
`- CERT_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,10 +185,52 @@ const verifyLOG_FORMAT_MORGAN = (): string[] => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyRUN_TIMES = (): string[] => {
|
||||
const errors: string[] = []
|
||||
const { RUN_TIMES } = process.env
|
||||
|
||||
if (RUN_TIMES) {
|
||||
const runTimes = RUN_TIMES.split(',')
|
||||
|
||||
const runTimeTypes = Object.values(RunTimeType)
|
||||
|
||||
runTimes.forEach((runTime) => {
|
||||
if (!runTimeTypes.includes(runTime as RunTimeType)) {
|
||||
errors.push(
|
||||
`- Invalid '${runTime}' runtime\n - valid options ${runTimeTypes}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
process.env.RUN_TIMES = DEFAULTS.RUN_TIMES
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyExecutablePaths = () => {
|
||||
const errors: string[] = []
|
||||
const { RUN_TIMES, SAS_PATH, NODE_PATH, MODE } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
const runTimes = RUN_TIMES?.split(',')
|
||||
|
||||
if (runTimes?.includes(RunTimeType.SAS) && !SAS_PATH) {
|
||||
errors.push(`- SAS_PATH is required for ${RunTimeType.SAS} run time`)
|
||||
}
|
||||
|
||||
if (runTimes?.includes(RunTimeType.JS) && !NODE_PATH) {
|
||||
errors.push(`- NODE_PATH is required for ${RunTimeType.JS} run time`)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
MODE: ModeType.Desktop,
|
||||
PROTOCOL: ProtocolType.HTTP,
|
||||
PORT: '5000',
|
||||
HELMET_COEP: HelmetCoepType.TRUE,
|
||||
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common
|
||||
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
|
||||
RUN_TIMES: RunTimeType.SAS
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ export const extractJSONFromZip = async (zipFile: Express.Multer.File) => {
|
||||
|
||||
for await (const entry of zip) {
|
||||
const fileName = entry.path as string
|
||||
if (fileName.toUpperCase().endsWith('.JSON') && fileName === fileInZip) {
|
||||
// grab the first json found in .zip
|
||||
if (fileName.toUpperCase().endsWith('.JSON')) {
|
||||
fileContent = await entry.buffer()
|
||||
break
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"tags": [
|
||||
{
|
||||
"name": "Info",
|
||||
"description": "Get Server Info"
|
||||
"description": "Get Server Information"
|
||||
},
|
||||
{
|
||||
"name": "Session",
|
||||
@@ -21,7 +21,11 @@
|
||||
},
|
||||
{
|
||||
"name": "User",
|
||||
"description": "Operations about users"
|
||||
"description": "Operations with users"
|
||||
},
|
||||
{
|
||||
"name": "Permission",
|
||||
"description": "Operations about permissions"
|
||||
},
|
||||
{
|
||||
"name": "Client",
|
||||
@@ -33,19 +37,19 @@
|
||||
},
|
||||
{
|
||||
"name": "Drive",
|
||||
"description": "Operations about drive"
|
||||
"description": "Operations on SASjs Drive"
|
||||
},
|
||||
{
|
||||
"name": "Group",
|
||||
"description": "Operations about group"
|
||||
"description": "Operations on groups and group memberships"
|
||||
},
|
||||
{
|
||||
"name": "STP",
|
||||
"description": "Operations about STP"
|
||||
"description": "Execution of Stored Programs"
|
||||
},
|
||||
{
|
||||
"name": "CODE",
|
||||
"description": "Operations on SAS code"
|
||||
"description": "Execution of code (various runtimes are supported)"
|
||||
},
|
||||
{
|
||||
"name": "Web",
|
||||
|
||||
@@ -1 +1 @@
|
||||
v16.14.0
|
||||
v16.15.1
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "npx webpack-dev-server --config webpack.dev.ts --hot",
|
||||
"build": "npx webpack --config webpack.prod.ts"
|
||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||
"build": "webpack --config webpack.prod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
|
||||
35
web/src/components/dialogTitle.tsx
Normal file
35
web/src/components/dialogTitle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
|
||||
export interface DialogTitleProps {
|
||||
id: string
|
||||
children?: React.ReactNode
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const BootstrapDialogTitle = (props: DialogTitleProps) => {
|
||||
const { children, handleOpen, ...other } = props
|
||||
|
||||
return (
|
||||
<DialogTitle sx={{ m: 0, p: 2 }} {...other}>
|
||||
{children}
|
||||
{handleOpen ? (
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={() => handleOpen(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
color: (theme) => theme.palette.grey[500]
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
)
|
||||
}
|
||||
@@ -144,6 +144,18 @@ const Header = (props: any) => {
|
||||
open={!!anchorEl}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
href={'https://server.sasjs.io'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
</MenuItem>
|
||||
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -9,8 +9,8 @@ const Home = () => {
|
||||
<CssBaseline />
|
||||
<h2>Welcome to SASjs Server!</h2>
|
||||
<p>
|
||||
This portal provides an interface for executing Stored Programs (drive)
|
||||
and ad hoc code (studio) against a SAS executable. The source code is
|
||||
SASjs Server provides a REST interface for executing Stored Programs and
|
||||
ad hoc code (studio) against SAS and JS executables. The source is
|
||||
available on{' '}
|
||||
<a
|
||||
href="https://github.com/sasjs/server"
|
||||
|
||||
@@ -22,7 +22,7 @@ const Login = () => {
|
||||
username,
|
||||
password
|
||||
}).catch((err: any) => {
|
||||
setErrorMessage(err.response.data)
|
||||
setErrorMessage(err.response?.data || err.toString())
|
||||
return {}
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ const Login = () => {
|
||||
appContext.setUserId?.(user.id)
|
||||
appContext.setUsername?.(user.username)
|
||||
appContext.setDisplayName?.(user.displayName)
|
||||
appContext.setIsAdmin?.(user.isAdmin)
|
||||
appContext.setLoggedIn?.(loggedIn)
|
||||
}
|
||||
}
|
||||
|
||||
43
web/src/components/modal.tsx
Normal file
43
web/src/components/modal.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Typography, Dialog, DialogContent } from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
|
||||
import { BootstrapDialogTitle } from './dialogTitle'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
'& .MuiDialogActions-root': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
}))
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
title: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
const Modal = (props: ModalProps) => {
|
||||
const { open, setOpen, title, payload } = props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
|
||||
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
|
||||
{title}
|
||||
</BootstrapDialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography gutterBottom>
|
||||
<span style={{ fontFamily: 'monospace' }}>{payload}</span>
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
</BootstrapDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
||||
62
web/src/components/snackbar.tsx
Normal file
62
web/src/components/snackbar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
import Snackbar from '@mui/material/Snackbar'
|
||||
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
||||
import Slide, { SlideProps } from '@mui/material/Slide'
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||
})
|
||||
|
||||
const Transition = (props: SlideProps) => {
|
||||
return <Slide {...props} direction="up" />
|
||||
}
|
||||
|
||||
export enum AlertSeverityType {
|
||||
Success = 'success',
|
||||
Warning = 'warning',
|
||||
Info = 'info',
|
||||
Error = 'error'
|
||||
}
|
||||
|
||||
type BootstrapSnackbarProps = {
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
message: string
|
||||
severity: AlertSeverityType
|
||||
}
|
||||
|
||||
const BootstrapSnackbar = ({
|
||||
open,
|
||||
setOpen,
|
||||
message,
|
||||
severity
|
||||
}: BootstrapSnackbarProps) => {
|
||||
const handleClose = (
|
||||
event: React.SyntheticEvent | Event,
|
||||
reason?: string
|
||||
) => {
|
||||
if (reason === 'clickaway') {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={3000}
|
||||
onClose={handleClose}
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default BootstrapSnackbar
|
||||
@@ -94,10 +94,7 @@ const Main = (props: Props) => {
|
||||
setEditMode(false)
|
||||
} else {
|
||||
window.open(
|
||||
`${baseUrl}/SASjsApi/stp/execute?_program=${props.selectedFilePath.replace(
|
||||
/.sas$/,
|
||||
''
|
||||
)}`
|
||||
`${baseUrl}/SASjsApi/stp/execute?_program=${props.selectedFilePath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
214
web/src/containers/Settings/addPermissionModal.tsx
Normal file
214
web/src/containers/Settings/addPermissionModal.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Autocomplete
|
||||
} from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
|
||||
import { BootstrapDialogTitle } from '../../components/dialogTitle'
|
||||
|
||||
import {
|
||||
UserResponse,
|
||||
GroupResponse,
|
||||
RegisterPermissionPayload
|
||||
} from '../../utils/types'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
'& .MuiDialogActions-root': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
}))
|
||||
|
||||
type AddPermissionModalProps = {
|
||||
open: boolean
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
addPermission: (addPermissionPayload: RegisterPermissionPayload) => void
|
||||
}
|
||||
|
||||
const AddPermissionModal = ({
|
||||
open,
|
||||
handleOpen,
|
||||
addPermission
|
||||
}: AddPermissionModalProps) => {
|
||||
const [URIs, setURIs] = useState<string[]>([])
|
||||
const [loadingURIs, setLoadingURIs] = useState(false)
|
||||
const [uri, setUri] = useState<string>()
|
||||
const [principalType, setPrincipalType] = useState('user')
|
||||
const [userPrincipal, setUserPrincipal] = useState<UserResponse>()
|
||||
const [groupPrincipal, setGroupPrincipal] = useState<GroupResponse>()
|
||||
const [permissionSetting, setPermissionSetting] = useState('Grant')
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(false)
|
||||
const [userPrincipals, setUserPrincipals] = useState<UserResponse[]>([])
|
||||
const [groupPrincipals, setGroupPrincipals] = useState<GroupResponse[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingURIs(true)
|
||||
axios
|
||||
.get('/SASjsApi/info/authorizedRoutes')
|
||||
.then((res: any) => {
|
||||
if (res.data) {
|
||||
setURIs(res.data.URIs)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingURIs(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingPrincipals(true)
|
||||
axios
|
||||
.get(`/SASjsApi/${principalType}`)
|
||||
.then((res: any) => {
|
||||
if (res.data) {
|
||||
if (principalType === 'user') {
|
||||
const users: UserResponse[] = res.data
|
||||
const nonAdminUsers = users.filter((user) => !user.isAdmin)
|
||||
setUserPrincipals(nonAdminUsers)
|
||||
} else {
|
||||
setGroupPrincipals(res.data)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingPrincipals(false)
|
||||
})
|
||||
}, [principalType])
|
||||
|
||||
const handleAddPermission = () => {
|
||||
const addPermissionPayload: any = {
|
||||
uri,
|
||||
setting: permissionSetting,
|
||||
principalType
|
||||
}
|
||||
if (principalType === 'user' && userPrincipal) {
|
||||
addPermissionPayload.principalId = userPrincipal.id
|
||||
} else if (principalType === 'group' && groupPrincipal) {
|
||||
addPermissionPayload.principalId = groupPrincipal.groupId
|
||||
}
|
||||
addPermission(addPermissionPayload)
|
||||
}
|
||||
|
||||
const addButtonDisabled =
|
||||
!uri || (principalType === 'user' ? !userPrincipal : !groupPrincipal)
|
||||
|
||||
return (
|
||||
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
|
||||
<BootstrapDialogTitle
|
||||
id="add-permission-dialog-title"
|
||||
handleOpen={handleOpen}
|
||||
>
|
||||
Add Permission
|
||||
</BootstrapDialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={URIs}
|
||||
disableClearable
|
||||
value={uri}
|
||||
onChange={(event: any, newValue: string) => setUri(newValue)}
|
||||
renderInput={(params) =>
|
||||
loadingURIs ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<TextField {...params} label="Principal" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={['user', 'group']}
|
||||
disableClearable
|
||||
value={principalType}
|
||||
onChange={(event: any, newValue: string) =>
|
||||
setPrincipalType(newValue)
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Principal Type" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
{principalType === 'user' ? (
|
||||
<Autocomplete
|
||||
options={userPrincipals}
|
||||
getOptionLabel={(option) => option.displayName}
|
||||
disableClearable
|
||||
value={userPrincipal}
|
||||
onChange={(event: any, newValue: UserResponse) =>
|
||||
setUserPrincipal(newValue)
|
||||
}
|
||||
renderInput={(params) =>
|
||||
loadingPrincipals ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<TextField {...params} label="Principal" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Autocomplete
|
||||
options={groupPrincipals}
|
||||
getOptionLabel={(option) => option.name}
|
||||
disableClearable
|
||||
value={groupPrincipal}
|
||||
onChange={(event: any, newValue: GroupResponse) =>
|
||||
setGroupPrincipal(newValue)
|
||||
}
|
||||
renderInput={(params) =>
|
||||
loadingPrincipals ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<TextField {...params} label="Principal" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={['Grant', 'Deny']}
|
||||
disableClearable
|
||||
value={permissionSetting}
|
||||
onChange={(event: any, newValue: string) =>
|
||||
setPermissionSetting(newValue)
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Settings" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddPermission}
|
||||
disabled={addButtonDisabled}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</BootstrapDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddPermissionModal
|
||||
44
web/src/containers/Settings/deletePermissionModal.tsx
Normal file
44
web/src/containers/Settings/deletePermissionModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
'& .MuiDialogActions-root': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
}))
|
||||
|
||||
type DeleteModalProps = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
deletePermission: () => void
|
||||
}
|
||||
|
||||
const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => {
|
||||
return (
|
||||
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
|
||||
<DialogContent dividers>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to delete this permission?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="error" onClick={() => deletePermission()}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</BootstrapDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteModal
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import React, { useState, useContext } from 'react'
|
||||
|
||||
import { Box, Paper, Tab, styled } from '@mui/material'
|
||||
import TabContext from '@mui/lab/TabContext'
|
||||
import TabList from '@mui/lab/TabList'
|
||||
import TabPanel from '@mui/lab/TabPanel'
|
||||
|
||||
import Permission from './permission'
|
||||
import Profile from './profile'
|
||||
|
||||
import { AppContext, ModeType } from '../../context/appContext'
|
||||
|
||||
const StyledTab = styled(Tab)({
|
||||
background: 'black',
|
||||
margin: '0 5px 5px 0'
|
||||
@@ -17,7 +20,8 @@ const StyledTabpanel = styled(TabPanel)({
|
||||
})
|
||||
|
||||
const Settings = () => {
|
||||
const [value, setValue] = React.useState('profile')
|
||||
const appContext = useContext(AppContext)
|
||||
const [value, setValue] = useState('profile')
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setValue(newValue)
|
||||
@@ -42,11 +46,17 @@ const Settings = () => {
|
||||
onChange={handleChange}
|
||||
>
|
||||
<StyledTab label="Profile" value="profile" />
|
||||
{appContext.mode === ModeType.Server && (
|
||||
<StyledTab label="Uri Access" value="permission" />
|
||||
)}
|
||||
</TabList>
|
||||
</Box>
|
||||
<StyledTabpanel value="profile">
|
||||
<Profile />
|
||||
</StyledTabpanel>
|
||||
<StyledTabpanel value="permission">
|
||||
<Permission />
|
||||
</StyledTabpanel>
|
||||
</TabContext>
|
||||
</Box>
|
||||
)
|
||||
|
||||
483
web/src/containers/Settings/permission.tsx
Normal file
483
web/src/containers/Settings/permission.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import React, { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Popover
|
||||
} from '@mui/material'
|
||||
|
||||
import FilterListIcon from '@mui/icons-material/FilterList'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
|
||||
import { styled } from '@mui/material/styles'
|
||||
|
||||
import Modal from '../../components/modal'
|
||||
import PermissionFilterModal from './permissionFilterModal'
|
||||
import AddPermissionModal from './addPermissionModal'
|
||||
import UpdatePermissionModal from './updatePermissionModal'
|
||||
import DeleteModal from './deletePermissionModal'
|
||||
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
|
||||
|
||||
import {
|
||||
GroupDetailsResponse,
|
||||
PermissionResponse,
|
||||
RegisterPermissionPayload
|
||||
} from '../../utils/types'
|
||||
import { AppContext } from '../../context/appContext'
|
||||
|
||||
const BootstrapTableCell = styled(TableCell)({
|
||||
textAlign: 'left'
|
||||
})
|
||||
|
||||
export enum PrincipalType {
|
||||
User = 'User',
|
||||
Group = 'Group'
|
||||
}
|
||||
|
||||
const Permission = () => {
|
||||
const appContext = useContext(AppContext)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [openModal, setOpenModal] = useState(false)
|
||||
const [modalTitle, setModalTitle] = useState('')
|
||||
const [modalPayload, setModalPayload] = useState('')
|
||||
const [openSnackbar, setOpenSnackbar] = useState(false)
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('')
|
||||
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertSeverityType>(
|
||||
AlertSeverityType.Success
|
||||
)
|
||||
const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false)
|
||||
const [updatePermissionModalOpen, setUpdatePermissionModalOpen] =
|
||||
useState(false)
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [selectedPermission, setSelectedPermission] =
|
||||
useState<PermissionResponse>()
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [uriFilter, setUriFilter] = useState<string[]>([])
|
||||
const [principalFilter, setPrincipalFilter] = useState<string[]>([])
|
||||
const [principalTypeFilter, setPrincipalTypeFilter] = useState<
|
||||
PrincipalType[]
|
||||
>([])
|
||||
const [settingFilter, setSettingFilter] = useState<string[]>([])
|
||||
const [permissions, setPermissions] = useState<PermissionResponse[]>([])
|
||||
const [filteredPermissions, setFilteredPermissions] = useState<
|
||||
PermissionResponse[]
|
||||
>([])
|
||||
const [filterApplied, setFilterApplied] = useState(false)
|
||||
|
||||
const fetchPermissions = useCallback(() => {
|
||||
axios
|
||||
.get(`/SASjsApi/permission`)
|
||||
.then((res: any) => {
|
||||
if (res.data?.length > 0) {
|
||||
setPermissions(res.data)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setModalTitle('Abort')
|
||||
setModalPayload(
|
||||
typeof err.response.data === 'object'
|
||||
? JSON.stringify(err.response.data)
|
||||
: err.response.data
|
||||
)
|
||||
setOpenModal(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions()
|
||||
}, [fetchPermissions])
|
||||
|
||||
/**
|
||||
* first find the permissions w.r.t each filter type
|
||||
* take intersection of resultant arrays
|
||||
*/
|
||||
const applyFilter = () => {
|
||||
setFilterModalOpen(false)
|
||||
|
||||
const uriFilteredPermissions =
|
||||
uriFilter.length > 0
|
||||
? permissions.filter((permission) => uriFilter.includes(permission.uri))
|
||||
: permissions
|
||||
|
||||
const principalFilteredPermissions =
|
||||
principalFilter.length > 0
|
||||
? permissions.filter((permission) => {
|
||||
if (permission.user) {
|
||||
return principalFilter.includes(permission.user.username)
|
||||
}
|
||||
if (permission.group) {
|
||||
return principalFilter.includes(permission.group.name)
|
||||
}
|
||||
return false
|
||||
})
|
||||
: permissions
|
||||
|
||||
const principalTypeFilteredPermissions =
|
||||
principalTypeFilter.length > 0
|
||||
? permissions.filter((permission) => {
|
||||
if (permission.user) {
|
||||
return principalTypeFilter.includes(PrincipalType.User)
|
||||
}
|
||||
if (permission.group) {
|
||||
return principalTypeFilter.includes(PrincipalType.Group)
|
||||
}
|
||||
return false
|
||||
})
|
||||
: permissions
|
||||
|
||||
const settingFilteredPermissions =
|
||||
settingFilter.length > 0
|
||||
? permissions.filter((permission) =>
|
||||
settingFilter.includes(permission.setting)
|
||||
)
|
||||
: permissions
|
||||
|
||||
let filteredArray = uriFilteredPermissions.filter((permission) =>
|
||||
principalFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
principalTypeFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
settingFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
)
|
||||
|
||||
setFilteredPermissions(filteredArray)
|
||||
setFilterApplied(true)
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
setFilterModalOpen(false)
|
||||
setUriFilter([])
|
||||
setPrincipalFilter([])
|
||||
setSettingFilter([])
|
||||
setFilteredPermissions([])
|
||||
setFilterApplied(false)
|
||||
}
|
||||
|
||||
const addPermission = (addPermissionPayload: RegisterPermissionPayload) => {
|
||||
setAddPermissionModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.post('/SASjsApi/permission', addPermissionPayload)
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission added!')
|
||||
setSnackbarSeverity(AlertSeverityType.Success)
|
||||
setOpenSnackbar(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setModalTitle('Abort')
|
||||
setModalPayload(
|
||||
typeof err.response.data === 'object'
|
||||
? JSON.stringify(err.response.data)
|
||||
: err.response.data
|
||||
)
|
||||
setOpenModal(true)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdatePermissionClick = (permission: PermissionResponse) => {
|
||||
setSelectedPermission(permission)
|
||||
setUpdatePermissionModalOpen(true)
|
||||
}
|
||||
|
||||
const updatePermission = (setting: string) => {
|
||||
setUpdatePermissionModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, {
|
||||
setting
|
||||
})
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission updated!')
|
||||
setSnackbarSeverity(AlertSeverityType.Success)
|
||||
setOpenSnackbar(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setModalTitle('Abort')
|
||||
setModalPayload(
|
||||
typeof err.response.data === 'object'
|
||||
? JSON.stringify(err.response.data)
|
||||
: err.response.data
|
||||
)
|
||||
setOpenModal(true)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setSelectedPermission(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePermissionClick = (permission: PermissionResponse) => {
|
||||
setSelectedPermission(permission)
|
||||
setDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
const deletePermission = () => {
|
||||
setDeleteModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission deleted!')
|
||||
setSnackbarSeverity(AlertSeverityType.Success)
|
||||
setOpenSnackbar(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setModalTitle('Abort')
|
||||
setModalPayload(
|
||||
typeof err.response.data === 'object'
|
||||
? JSON.stringify(err.response.data)
|
||||
: err.response.data
|
||||
)
|
||||
setOpenModal(true)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setSelectedPermission(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
return isLoading ? (
|
||||
<CircularProgress
|
||||
style={{ position: 'absolute', left: '50%', top: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<Box className="permissions-page">
|
||||
<Grid container direction="column" spacing={1}>
|
||||
<Grid item xs={12}>
|
||||
<Paper elevation={3} sx={{ display: 'flex' }}>
|
||||
<Tooltip title="Filter Permissions">
|
||||
<IconButton>
|
||||
<FilterListIcon onClick={() => setFilterModalOpen(true)} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{appContext.isAdmin && (
|
||||
<Tooltip
|
||||
sx={{ marginLeft: 'auto' }}
|
||||
title="Add Permission"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<IconButton onClick={() => setAddPermissionModalOpen(true)}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<PermissionTable
|
||||
permissions={filterApplied ? filteredPermissions : permissions}
|
||||
handleUpdatePermissionClick={handleUpdatePermissionClick}
|
||||
handleDeletePermissionClick={handleDeletePermissionClick}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<BootstrapSnackbar
|
||||
open={openSnackbar}
|
||||
setOpen={setOpenSnackbar}
|
||||
message={snackbarMessage}
|
||||
severity={snackbarSeverity}
|
||||
/>
|
||||
<Modal
|
||||
open={openModal}
|
||||
setOpen={setOpenModal}
|
||||
title={modalTitle}
|
||||
payload={modalPayload}
|
||||
/>
|
||||
<PermissionFilterModal
|
||||
open={filterModalOpen}
|
||||
handleOpen={setFilterModalOpen}
|
||||
permissions={permissions}
|
||||
uriFilter={uriFilter}
|
||||
setUriFilter={setUriFilter}
|
||||
principalFilter={principalFilter}
|
||||
setPrincipalFilter={setPrincipalFilter}
|
||||
principalTypeFilter={principalTypeFilter}
|
||||
setPrincipalTypeFilter={setPrincipalTypeFilter}
|
||||
settingFilter={settingFilter}
|
||||
setSettingFilter={setSettingFilter}
|
||||
applyFilter={applyFilter}
|
||||
resetFilter={resetFilter}
|
||||
/>
|
||||
<AddPermissionModal
|
||||
open={addPermissionModalOpen}
|
||||
handleOpen={setAddPermissionModalOpen}
|
||||
addPermission={addPermission}
|
||||
/>
|
||||
<UpdatePermissionModal
|
||||
open={updatePermissionModalOpen}
|
||||
handleOpen={setUpdatePermissionModalOpen}
|
||||
permission={selectedPermission}
|
||||
updatePermission={updatePermission}
|
||||
/>
|
||||
<DeleteModal
|
||||
open={deleteModalOpen}
|
||||
setOpen={setDeleteModalOpen}
|
||||
deletePermission={deletePermission}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Permission
|
||||
|
||||
type PermissionTableProps = {
|
||||
permissions: PermissionResponse[]
|
||||
handleUpdatePermissionClick: (permission: PermissionResponse) => void
|
||||
handleDeletePermissionClick: (permission: PermissionResponse) => void
|
||||
}
|
||||
|
||||
const PermissionTable = ({
|
||||
permissions,
|
||||
handleUpdatePermissionClick,
|
||||
handleDeletePermissionClick
|
||||
}: PermissionTableProps) => {
|
||||
const appContext = useContext(AppContext)
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }}>
|
||||
<TableHead sx={{ background: 'rgb(0,0,0, 0.3)' }}>
|
||||
<TableRow>
|
||||
<BootstrapTableCell>Uri</BootstrapTableCell>
|
||||
<BootstrapTableCell>Principal</BootstrapTableCell>
|
||||
<BootstrapTableCell>Type</BootstrapTableCell>
|
||||
<BootstrapTableCell>Setting</BootstrapTableCell>
|
||||
{appContext.isAdmin && (
|
||||
<BootstrapTableCell>Action</BootstrapTableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{permissions.map((permission) => (
|
||||
<TableRow key={permission.permissionId}>
|
||||
<BootstrapTableCell>{permission.uri}</BootstrapTableCell>
|
||||
<BootstrapTableCell>
|
||||
{displayPrincipal(permission)}
|
||||
</BootstrapTableCell>
|
||||
<BootstrapTableCell>
|
||||
{displayPrincipalType(permission)}
|
||||
</BootstrapTableCell>
|
||||
<BootstrapTableCell>{permission.setting}</BootstrapTableCell>
|
||||
{appContext.isAdmin && (
|
||||
<BootstrapTableCell>
|
||||
<Tooltip title="Edit Permission">
|
||||
<IconButton
|
||||
onClick={() => handleUpdatePermissionClick(permission)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Permission">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeletePermissionClick(permission)}
|
||||
>
|
||||
<DeleteForeverIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</BootstrapTableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const displayPrincipal = (permission: PermissionResponse) => {
|
||||
if (permission.user) return permission.user.username
|
||||
if (permission.group) return <DisplayGroup group={permission.group} />
|
||||
}
|
||||
|
||||
type DisplayGroupProps = {
|
||||
group: GroupDetailsResponse
|
||||
}
|
||||
|
||||
const DisplayGroup = ({ group }: DisplayGroupProps) => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
const handlePopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePopoverClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography
|
||||
aria-owns={open ? 'mouse-over-popover' : undefined}
|
||||
aria-haspopup="true"
|
||||
onMouseEnter={handlePopoverOpen}
|
||||
onMouseLeave={handlePopoverClose}
|
||||
>
|
||||
{group.name}
|
||||
</Typography>
|
||||
<Popover
|
||||
id="mouse-over-popover"
|
||||
sx={{
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left'
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'left'
|
||||
}}
|
||||
onClose={handlePopoverClose}
|
||||
disableRestoreFocus
|
||||
>
|
||||
<Typography sx={{ p: 1 }} variant="h6" component="div">
|
||||
Group Members
|
||||
</Typography>
|
||||
{group.users.map((user) => (
|
||||
<Typography sx={{ p: 1 }} component="li">
|
||||
{user.username}
|
||||
</Typography>
|
||||
))}
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displayPrincipalType = (permission: PermissionResponse) => {
|
||||
if (permission.user) return PrincipalType.User
|
||||
if (permission.group) return PrincipalType.Group
|
||||
}
|
||||
154
web/src/containers/Settings/permissionFilterModal.tsx
Normal file
154
web/src/containers/Settings/permissionFilterModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import Autocomplete from '@mui/material/Autocomplete'
|
||||
|
||||
import { PermissionResponse } from '../../utils/types'
|
||||
import { BootstrapDialogTitle } from '../../components/dialogTitle'
|
||||
import { PrincipalType } from './permission'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
'& .MuiDialogActions-root': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
}))
|
||||
|
||||
type FilterModalProps = {
|
||||
open: boolean
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
permissions: PermissionResponse[]
|
||||
uriFilter: string[]
|
||||
setUriFilter: Dispatch<SetStateAction<string[]>>
|
||||
principalFilter: string[]
|
||||
setPrincipalFilter: Dispatch<SetStateAction<string[]>>
|
||||
principalTypeFilter: PrincipalType[]
|
||||
setPrincipalTypeFilter: Dispatch<SetStateAction<PrincipalType[]>>
|
||||
settingFilter: string[]
|
||||
setSettingFilter: Dispatch<SetStateAction<string[]>>
|
||||
applyFilter: () => void
|
||||
resetFilter: () => void
|
||||
}
|
||||
|
||||
const PermissionFilterModal = ({
|
||||
open,
|
||||
handleOpen,
|
||||
permissions,
|
||||
uriFilter,
|
||||
setUriFilter,
|
||||
principalFilter,
|
||||
setPrincipalFilter,
|
||||
principalTypeFilter,
|
||||
setPrincipalTypeFilter,
|
||||
settingFilter,
|
||||
setSettingFilter,
|
||||
applyFilter,
|
||||
resetFilter
|
||||
}: FilterModalProps) => {
|
||||
const URIs = permissions
|
||||
.map((permission) => permission.uri)
|
||||
.filter((uri, index, array) => array.indexOf(uri) === index)
|
||||
|
||||
// fetch all the principals from permissions array
|
||||
let principals = permissions.map((permission) => {
|
||||
if (permission.user) return permission.user.username
|
||||
if (permission.group) return permission.group.name
|
||||
return ''
|
||||
})
|
||||
|
||||
// removes empty strings
|
||||
principals = principals.filter((principal) => principal !== '')
|
||||
|
||||
// removes the duplicates
|
||||
principals = principals.filter(
|
||||
(principal, index, array) => array.indexOf(principal) === index
|
||||
)
|
||||
|
||||
return (
|
||||
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
|
||||
<BootstrapDialogTitle
|
||||
id="permission-filter-dialog-title"
|
||||
handleOpen={handleOpen}
|
||||
>
|
||||
Permission Filter
|
||||
</BootstrapDialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={URIs}
|
||||
filterSelectedOptions
|
||||
value={uriFilter}
|
||||
onChange={(event: any, newValue: string[]) => {
|
||||
setUriFilter(newValue)
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="URIs" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={principals}
|
||||
filterSelectedOptions
|
||||
value={principalFilter}
|
||||
onChange={(event: any, newValue: string[]) => {
|
||||
setPrincipalFilter(newValue)
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Principals" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={Object.values(PrincipalType)}
|
||||
filterSelectedOptions
|
||||
value={principalTypeFilter}
|
||||
onChange={(event: any, newValue: PrincipalType[]) => {
|
||||
setPrincipalTypeFilter(newValue)
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Principal Type" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={['Grant', 'Deny']}
|
||||
filterSelectedOptions
|
||||
value={settingFilter}
|
||||
onChange={(event: any, newValue: string[]) => {
|
||||
setSettingFilter(newValue)
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Settings" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="outlined" color="error" onClick={resetFilter}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={applyFilter}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</BootstrapDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionFilterModal
|
||||
84
web/src/containers/Settings/updatePermissionModal.tsx
Normal file
84
web/src/containers/Settings/updatePermissionModal.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState, Dispatch, SetStateAction, useEffect } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import Autocomplete from '@mui/material/Autocomplete'
|
||||
|
||||
import { BootstrapDialogTitle } from '../../components/dialogTitle'
|
||||
|
||||
import { PermissionResponse } from '../../utils/types'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
'& .MuiDialogActions-root': {
|
||||
padding: theme.spacing(1)
|
||||
}
|
||||
}))
|
||||
|
||||
type UpdatePermissionModalProps = {
|
||||
open: boolean
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
permission: PermissionResponse | undefined
|
||||
updatePermission: (setting: string) => void
|
||||
}
|
||||
|
||||
const UpdatePermissionModal = ({
|
||||
open,
|
||||
handleOpen,
|
||||
permission,
|
||||
updatePermission
|
||||
}: UpdatePermissionModalProps) => {
|
||||
const [permissionSetting, setPermissionSetting] = useState('Grant')
|
||||
|
||||
useEffect(() => {
|
||||
if (permission) setPermissionSetting(permission.setting)
|
||||
}, [permission])
|
||||
|
||||
return (
|
||||
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
|
||||
<BootstrapDialogTitle
|
||||
id="add-permission-dialog-title"
|
||||
handleOpen={handleOpen}
|
||||
>
|
||||
Update Permission
|
||||
</BootstrapDialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
sx={{ width: 300 }}
|
||||
options={['Grant', 'Deny']}
|
||||
disableClearable
|
||||
value={permissionSetting}
|
||||
onChange={(event: any, newValue: string) =>
|
||||
setPermissionSetting(newValue)
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Settings" />
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => updatePermission(permissionSetting)}
|
||||
disabled={permission?.setting === permissionSetting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</BootstrapDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdatePermissionModal
|
||||
@@ -1,13 +1,26 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState, useContext } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import { Button, Paper, Stack, Tab, Tooltip } from '@mui/material'
|
||||
import {
|
||||
Backdrop,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Tab,
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import { makeStyles } from '@mui/styles'
|
||||
import Editor, { EditorDidMount } from 'react-monaco-editor'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { TabContext, TabList, TabPanel } from '@mui/lab'
|
||||
|
||||
import { AppContext, RunTimeType } from '../../context/appContext'
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
fontSize: '1rem',
|
||||
@@ -30,12 +43,24 @@ const useStyles = makeStyles(() => ({
|
||||
}))
|
||||
|
||||
const Studio = () => {
|
||||
const appContext = useContext(AppContext)
|
||||
const location = useLocation()
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [log, setLog] = useState('')
|
||||
const [ctrlPressed, setCtrlPressed] = useState(false)
|
||||
const [webout, setWebout] = useState('')
|
||||
const [tab, setTab] = React.useState('1')
|
||||
const [tab, setTab] = useState('1')
|
||||
const [runTimes, setRunTimes] = useState<string[]>([])
|
||||
const [selectedRunTime, setSelectedRunTime] = useState('')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setRunTimes(Object.values(appContext.runTimes))
|
||||
}, [appContext.runTimes])
|
||||
|
||||
useEffect(() => {
|
||||
if (runTimes.length) setSelectedRunTime(runTimes[0])
|
||||
}, [runTimes])
|
||||
|
||||
const handleTabChange = (_e: any, newValue: string) => {
|
||||
setTab(newValue)
|
||||
@@ -56,8 +81,9 @@ const Studio = () => {
|
||||
const handleRunBtnClick = () => runCode(getSelection() || fileContent)
|
||||
|
||||
const runCode = (code: string) => {
|
||||
setIsRunning(true)
|
||||
axios
|
||||
.post(`/SASjsApi/code/execute`, { code })
|
||||
.post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime })
|
||||
.then((res: any) => {
|
||||
const parsedLog = res?.data?.log
|
||||
.map((logLine: any) => logLine.line)
|
||||
@@ -72,6 +98,7 @@ const Studio = () => {
|
||||
window.scrollTo(0, document.body.scrollHeight)
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
.finally(() => setIsRunning(false))
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: any) => {
|
||||
@@ -89,6 +116,10 @@ const Studio = () => {
|
||||
if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false)
|
||||
}
|
||||
|
||||
const handleChangeRunTime = (event: SelectChangeEvent) => {
|
||||
setSelectedRunTime(event.target.value as RunTimeType)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const content = localStorage.getItem('fileContent') ?? ''
|
||||
setFileContent(content)
|
||||
@@ -136,7 +167,13 @@ const Studio = () => {
|
||||
</TabList>
|
||||
</Box>
|
||||
|
||||
<TabPanel style={{ paddingBottom: 0 }} value="1">
|
||||
<TabPanel sx={{ paddingBottom: 0 }} value="1">
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={isRunning}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
<div className={classes.subMenu}>
|
||||
<Tooltip title="CTRL+ENTER will also run SAS code">
|
||||
<Button onClick={handleRunBtnClick} className={classes.runButton}>
|
||||
@@ -149,8 +186,23 @@ const Studio = () => {
|
||||
<span style={{ fontSize: '12px' }}>RUN</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Box sx={{ minWidth: '75px', marginLeft: '10px' }}>
|
||||
<FormControl variant="standard">
|
||||
<Select
|
||||
labelId="run-time-select-label"
|
||||
id="run-time-select"
|
||||
value={selectedRunTime}
|
||||
onChange={handleChangeRunTime}
|
||||
>
|
||||
{runTimes.map((runTime) => (
|
||||
<MenuItem key={runTime} value={runTime}>
|
||||
{runTime}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</div>
|
||||
{/* <Toolbar /> */}
|
||||
<Paper
|
||||
sx={{
|
||||
height: 'calc(100vh - 170px)',
|
||||
|
||||
@@ -14,6 +14,11 @@ export enum ModeType {
|
||||
Desktop = 'desktop'
|
||||
}
|
||||
|
||||
export enum RunTimeType {
|
||||
SAS = 'sas',
|
||||
JS = 'js'
|
||||
}
|
||||
|
||||
interface AppContextProps {
|
||||
checkingSession: boolean
|
||||
loggedIn: boolean
|
||||
@@ -24,7 +29,10 @@ interface AppContextProps {
|
||||
setUsername: Dispatch<SetStateAction<string>> | null
|
||||
displayName: string
|
||||
setDisplayName: Dispatch<SetStateAction<string>> | null
|
||||
isAdmin: boolean
|
||||
setIsAdmin: Dispatch<SetStateAction<boolean>> | null
|
||||
mode: ModeType
|
||||
runTimes: RunTimeType[]
|
||||
logout: (() => void) | null
|
||||
}
|
||||
|
||||
@@ -38,7 +46,10 @@ export const AppContext = createContext<AppContextProps>({
|
||||
setUsername: null,
|
||||
displayName: '',
|
||||
setDisplayName: null,
|
||||
isAdmin: false,
|
||||
setIsAdmin: null,
|
||||
mode: ModeType.Server,
|
||||
runTimes: [],
|
||||
logout: null
|
||||
})
|
||||
|
||||
@@ -49,7 +60,9 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
const [userId, setUserId] = useState(0)
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [mode, setMode] = useState(ModeType.Server)
|
||||
const [runTimes, setRunTimes] = useState<RunTimeType[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setCheckingSession(true)
|
||||
@@ -62,6 +75,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
setUserId(data.id)
|
||||
setUsername(data.username)
|
||||
setDisplayName(data.displayName)
|
||||
setIsAdmin(data.isAdmin)
|
||||
setLoggedIn(true)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -74,12 +88,13 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
.then((res) => res.data)
|
||||
.then((data: any) => {
|
||||
setMode(data.mode)
|
||||
setRunTimes(data.runTimes)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
axios.get('/logout').then(() => {
|
||||
axios.get('/SASLogon/logout').then(() => {
|
||||
setLoggedIn(false)
|
||||
setUsername('')
|
||||
setDisplayName('')
|
||||
@@ -98,7 +113,10 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
setUsername,
|
||||
displayName,
|
||||
setDisplayName,
|
||||
isAdmin,
|
||||
setIsAdmin,
|
||||
mode,
|
||||
runTimes,
|
||||
logout
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -18,3 +18,10 @@ code {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.permissions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: '5px 10px';
|
||||
margin-top: '10px';
|
||||
}
|
||||
|
||||
32
web/src/utils/types.ts
Normal file
32
web/src/utils/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface GroupResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface GroupDetailsResponse extends GroupResponse {
|
||||
isActive: boolean
|
||||
users: UserResponse[]
|
||||
}
|
||||
|
||||
export interface PermissionResponse {
|
||||
permissionId: number
|
||||
uri: string
|
||||
setting: string
|
||||
user?: UserResponse
|
||||
group?: GroupDetailsResponse
|
||||
}
|
||||
|
||||
export interface RegisterPermissionPayload {
|
||||
uri: string
|
||||
setting: string
|
||||
principalType: string
|
||||
principalId: number
|
||||
}
|
||||
Reference in New Issue
Block a user