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

Compare commits

..

1 Commits

Author SHA1 Message Date
Saad Jutt
d1a02c0da5 chore: debugging certs 2022-06-21 02:08:43 +05:00
84 changed files with 1593 additions and 3490 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [sasjs]

View File

@@ -1,126 +1,3 @@
## [0.11.5](https://github.com/sasjs/server/compare/v0.11.4...v0.11.5) (2022-07-19)
### Bug Fixes
* Revert "fix(security): missing cookie flags are added" ([ce5218a](https://github.com/sasjs/server/commit/ce5218a2278cc750f2b1032024685dc6cd72f796))
## [0.11.4](https://github.com/sasjs/server/compare/v0.11.3...v0.11.4) (2022-07-19)
### Bug Fixes
* **security:** missing cookie flags are added ([526402f](https://github.com/sasjs/server/commit/526402fd73407ee4fa2d31092111a7e6a1741487))
## [0.11.3](https://github.com/sasjs/server/compare/v0.11.2...v0.11.3) (2022-07-19)
### Bug Fixes
* filePath fix in code.js file for windows ([2995121](https://github.com/sasjs/server/commit/299512135d77c2ac9e34853cf35aee6f2e1d4da4))
## [0.11.2](https://github.com/sasjs/server/compare/v0.11.1...v0.11.2) (2022-07-18)
### Bug Fixes
* apply icon option only for sas.exe ([d2ddd8a](https://github.com/sasjs/server/commit/d2ddd8aacadfdd143026881f2c6ae8c6b277610a))
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)
### Bug Fixes
* bank operator ([aa02741](https://github.com/sasjs/server/commit/aa027414ed3ce51f1014ef36c4191e064b2e963d))
* ensuring nosplash option only applies for sas.exe ([65e6de9](https://github.com/sasjs/server/commit/65e6de966383fe49a919b1f901d77c7f1e402c9b)), closes [#229](https://github.com/sasjs/server/issues/229)
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)
### Bug Fixes
* **logs:** logs location is configurable ([e024a92](https://github.com/sasjs/server/commit/e024a92f165990e08db8aa26ee326dbcb30e2e46))
### Features
* **logs:** logs to file with rotating + code split into files ([92fda18](https://github.com/sasjs/server/commit/92fda183f3f0f3956b7c791669eb8dd52c389d1b))
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)
### Bug Fixes
* add authorize middleware for appStreams ([e54a09d](https://github.com/sasjs/server/commit/e54a09db19ec8690e54a40760531a4e06d250974))
* add isAdmin attribute to return response of get session and login requests ([bdf63df](https://github.com/sasjs/server/commit/bdf63df1d915892486005ec904807749786b1c0c))
* add permission authorization middleware to only specific routes ([f3dfc70](https://github.com/sasjs/server/commit/f3dfc7083fbfb4b447521341b1a86730fb90b4c0))
* bumping core and running lint ([a2d1396](https://github.com/sasjs/server/commit/a2d13960578014312d2cb5e03145bfd1829d99ec))
* controller fixed for deleting permission ([b5f595a](https://github.com/sasjs/server/commit/b5f595a25c50550d62482409353c7629c5a5c3e0))
* do not show admin users in add permission modal ([a75edba](https://github.com/sasjs/server/commit/a75edbaa327ec2af49523c13996ac283061da7d8))
* export GroupResponse interface ([38a7db8](https://github.com/sasjs/server/commit/38a7db8514de0acd94d74ba96bc1efb732add30c))
* move permission filter modal to separate file and icons for different actions ([d000f75](https://github.com/sasjs/server/commit/d000f7508f6d7384afffafee4179151fca802ca8))
* principalId type changed to number from any ([4fcc191](https://github.com/sasjs/server/commit/4fcc191ce9edc7e4dcd8821fb8019f4eea5db4ea))
* remove clientId from principal types ([0781ddd](https://github.com/sasjs/server/commit/0781ddd64e3b5e5ca39647bb4e4e1a9332a0f4f8))
* remove duplicates principals from permission filter modal ([5b319f9](https://github.com/sasjs/server/commit/5b319f9ad1f941b306db6b9473a2128b2e42bf76))
* show loading spinner in studio while executing code ([496247d](https://github.com/sasjs/server/commit/496247d0b9975097a008cf4d3a999d77648fd930))
* show permission component only in server mode ([f863b81](https://github.com/sasjs/server/commit/f863b81a7d40a1296a061ec93946f204382af2c3))
* update permission model ([39fc908](https://github.com/sasjs/server/commit/39fc908de1945f2aaea18d14e6bce703f6bf0c06))
* update permission response ([e516b77](https://github.com/sasjs/server/commit/e516b7716da5ff7e23350a5f77cfa073b1171175))
* **web:** only admin should be able to add, update or delete permission ([be8635c](https://github.com/sasjs/server/commit/be8635ccc5eb34c3f0a5951c8a0421292ef69c97))
### Features
* add api endpoint for deleting permission ([0171344](https://github.com/sasjs/server/commit/01713440a4fa661b76368785c0ca731f096ac70a))
* add api endpoint for updating permission setting ([540f54f](https://github.com/sasjs/server/commit/540f54fb77b364822da7889dbe75c02242f48a59))
* add authorize middleware for validating permissions ([7d916ec](https://github.com/sasjs/server/commit/7d916ec3e9ef579dde1b73015715cd01098c2018))
* add basic UI for settings and permissions ([5652325](https://github.com/sasjs/server/commit/56523254525a66e756196e90b39a2b8cdadc1518))
* add documentation link under usename dropdown menu ([eeb63b3](https://github.com/sasjs/server/commit/eeb63b330c292afcdd5c8f006882b224c4235068))
* add permission model ([6bea1f7](https://github.com/sasjs/server/commit/6bea1f76668ddb070ad95b3e02c31238af67c346))
* add UI for updating permission ([e8c21a4](https://github.com/sasjs/server/commit/e8c21a43b215f5fced0463b70747cda1191a4e01))
* add validation for registering permission ([e5200c1](https://github.com/sasjs/server/commit/e5200c1000903185dfad9ee49c99583e473c4388))
* add, remove and update permissions from web component ([97ecfdc](https://github.com/sasjs/server/commit/97ecfdc95563c72dbdecaebcb504e5194250a763))
* added get authorizedRoutes api endpoint ([b10e932](https://github.com/sasjs/server/commit/b10e9326058193dd65a57fab2d2f05b7b06096e7))
* created modal for adding permission ([1413b18](https://github.com/sasjs/server/commit/1413b1850838ecc988ab289da4541bde36a9a346))
* defined register permission and get all permissions api endpoints ([1103ffe](https://github.com/sasjs/server/commit/1103ffe07b88496967cb03683b08f058ca3bbb9f))
* update swagger docs ([797c2bc](https://github.com/sasjs/server/commit/797c2bcc39005a05a995be15a150d584fecae259))
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)
### Features
* removed secrets from env variables ([9c3da56](https://github.com/sasjs/server/commit/9c3da56901672a818f54267f9defc9f4701ab7fb))
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)
### Bug Fixes
* **deploy:** extract first json from zip file ([e290751](https://github.com/sasjs/server/commit/e290751c872d24009482871a8c398e834357dcde))
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)
### Bug Fixes
* getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes ([5cc85b5](https://github.com/sasjs/server/commit/5cc85b57f80b13296156811fe966d7b37d45f213))
## [0.8.1](https://github.com/sasjs/server/compare/v0.8.0...v0.8.1) (2022-06-21)
### Bug Fixes
* make CA_ROOT optional in getCertificates method ([1b5859e](https://github.com/sasjs/server/commit/1b5859ee37ae73c419115b9debfd5141a79733de))
* update /logout route to /SASLogon/logout ([65380be](https://github.com/sasjs/server/commit/65380be2f3945bae559f1749064845b514447a53))
# [0.8.0](https://github.com/sasjs/server/compare/v0.7.3...v0.8.0) (2022-06-21)
### Features
* **certs:** ENV variables updated and set CA Root for HTTPS server ([2119e9d](https://github.com/sasjs/server/commit/2119e9de9ab1e5ce1222658f554ac74f4f35cf4d))
## [0.7.3](https://github.com/sasjs/server/compare/v0.7.2...v0.7.3) (2022-06-20)

View File

@@ -1,19 +0,0 @@
## 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.

View File

@@ -99,12 +99,15 @@ SASV9_OPTIONS= -NOXCMD
## Additional Web Server Options
#
# ENV variables for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem (required)
CERT_CHAIN=certificate.pem (required)
CA_ROOT=fullchain.pem (optional)
# ENV variables required for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem
FULL_CHAIN=fullchain.pem
# 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`
@@ -136,9 +139,6 @@ 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

View File

@@ -4,14 +4,17 @@ WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
PROTOCOL=[http|https] default considered as http
PRIVATE_KEY=privkey.pem
CERT_CHAIN=certificate.pem
CA_ROOT=fullchain.pem
FULL_CHAIN=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
@@ -20,5 +23,4 @@ NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
SASJS_ROOT=./sasjs_root
LOG_FORMAT_MORGAN=common
LOG_LOCATION=./sasjs_root/logs
LOG_FORMAT_MORGAN=common

1266
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
"prestart": "npm run initial",
"prebuild": "npm run initial",
"start": "NODE_ENV=development nodemon ./src/server.ts",
"start": "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.31.3",
"@sasjs/core": "^4.27.3",
"@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
@@ -63,7 +63,6 @@
"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"

View File

@@ -47,6 +47,41 @@ 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:
@@ -279,13 +314,10 @@ components:
type: string
displayName:
type: string
isAdmin:
type: boolean
required:
- id
- username
- displayName
- isAdmin
type: object
additionalProperties: false
GroupResponse:
@@ -408,13 +440,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_:
@@ -448,16 +480,6 @@ components:
- runTimes
type: object
additionalProperties: false
AuthorizedRoutesResponse:
properties:
URIs:
items:
type: string
type: array
required:
- URIs
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
@@ -466,106 +488,6 @@ 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
@@ -636,6 +558,86 @@ 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
@@ -761,7 +763,7 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'Deployment failed!'}
description: "Accepts JSON file and zipped compressed JSON file as well.\nCompressed file should only contain one JSON file and should have same name\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\nAny other file or JSON file in zipped will be ignored!"
description: "Accepts JSON file and zipped compressed JSON file as well.\r\nCompressed file should only contain one JSON file and should have same name\r\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\r\nAny other file or JSON file in zipped will be ignored!"
summary: 'Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.'
tags:
- Drive
@@ -849,7 +851,7 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'File request failed.'}
description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request."
description: "It's optional to either provide `_filePath` in url as query parameter\r\nOr provide `filePath` in body as form field.\r\nBut it's required to provide else API will respond with Bad Request."
summary: 'Create a file in SASjs Drive'
tags:
- Drive
@@ -900,7 +902,7 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'File request failed.'}
description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request."
description: "It's optional to either provide `_filePath` in url as query parameter\r\nOr provide `filePath` in body as form field.\r\nBut it's required to provide else API will respond with Bad Request."
summary: 'Modify a file in SASjs Drive'
tags:
- Drive
@@ -991,7 +993,7 @@ paths:
type: array
examples:
'Example 1':
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
value: [{id: 123, username: johnusername, displayName: John}, {id: 456, username: starkusername, displayName: Stark}]
summary: 'Get list of all users (username, displayname). All users can request this.'
tags:
- User
@@ -1420,24 +1422,6 @@ paths:
- 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
@@ -1450,7 +1434,7 @@ paths:
$ref: '#/components/schemas/UserResponse'
examples:
'Example 1':
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
value: {id: 123, username: johnusername, displayName: John}
summary: 'Get session info (username).'
tags:
- Session
@@ -1470,7 +1454,7 @@ paths:
anyOf:
- {type: string}
- {type: string, format: byte}
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"
description: "Trigger a SAS or JS program using the _program URL parameter.\r\n\r\nAccepts URL parameters and file uploads. For more details, see docs:\r\n\r\nhttps://server.sasjs.io/storedprograms"
summary: 'Execute a Stored Program, returns raw _webout content.'
tags:
- STP
@@ -1498,7 +1482,7 @@ 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 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."
description: "Trigger a SAS or JS program using the _program URL parameter.\r\n\r\nAccepts URL parameters and file uploads. For more details, see docs:\r\n\r\nhttps://server.sasjs.io/storedprograms\r\n\r\nThe response will be a JSON object with the following root attributes:\r\nlog, webout, headers.\r\n\r\nThe webout attribute will be nested JSON ONLY if the response-header\r\ncontains a content-type of application/json AND it is valid JSON.\r\nOtherwise it will be a stringified version of the webout content."
summary: 'Execute a Stored Program, return a JSON object'
tags:
- STP
@@ -1520,205 +1504,19 @@ paths:
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 Information'
description: 'Get Server Info'
-
name: Session
description: 'Get Session information'
-
name: User
description: 'Operations with users'
-
name: Permission
description: 'Operations about permissions'
description: 'Operations about users'
-
name: Client
description: 'Operations about clients'
@@ -1727,16 +1525,16 @@ tags:
description: 'Operations about auth'
-
name: Drive
description: 'Operations on SASjs Drive'
description: 'Operations about drive'
-
name: Group
description: 'Operations on groups and group memberships'
description: 'Operations about group'
-
name: STP
description: 'Execution of Stored Programs'
description: 'Operations about STP'
-
name: CODE
description: 'Execution of code (various runtimes are supported)'
description: 'Operations on SAS code'
-
name: Web
description: 'Operations on Web'

View File

@@ -6,7 +6,7 @@ import {
readFile,
SASJsFileType
} from '@sasjs/utils'
import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
import { apiRoot, sysInitCompiledPath } from '../src/utils'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')

View File

@@ -8,11 +8,7 @@ import {
listFilesInFolder
} from '@sasjs/utils'
import {
apiRoot,
sasJSCoreMacros,
sasJSCoreMacrosInfo
} from '../src/utils/file'
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')

View File

@@ -1,21 +0,0 @@
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 }))
}
}

View File

@@ -1,32 +0,0 @@
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
})
)
}
}

View File

@@ -1,33 +0,0 @@
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))
}

View File

@@ -1,26 +0,0 @@
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
})
)
}

View File

@@ -1,4 +0,0 @@
export * from './configureCors'
export * from './configureExpressSession'
export * from './configureLogger'
export * from './configureSecurity'

View File

@@ -1,26 +1,30 @@
import path from 'path'
import express, { ErrorRequestHandler } from 'express'
import csrf from 'csurf'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import morgan from 'morgan'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
import cors from 'cors'
import helmet from 'helmet'
import {
connectDB,
copySASjsCore,
CorsType,
getWebBuildFolder,
HelmetCoepType,
instantiateLogger,
loadAppStreamConfig,
ModeType,
ProtocolType,
ReturnCode,
setProcessVariables,
setupFolders,
verifyEnvVariables
} from './utils'
import {
configureCors,
configureExpressSession,
configureLogger,
configureSecurity
} from './app-modules'
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
dotenv.config()
@@ -30,7 +34,19 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express()
const { PROTOCOL } = process.env
app.use(cookieParser())
const {
MODE,
CORS,
WHITELIST,
PROTOCOL,
HELMET_CSP_CONFIG_PATH,
HELMET_COEP,
LOG_FORMAT_MORGAN
} = process.env
app.use(morgan(LOG_FORMAT_MORGAN as string))
export const cookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
@@ -38,11 +54,79 @@ 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!')
@@ -52,30 +136,6 @@ 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()

View File

@@ -129,8 +129,8 @@ const verifyAuthCode = async (
clientId: string,
code: string
): Promise<InfoJWT | undefined> => {
return new Promise((resolve) => {
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
return new Promise((resolve, reject) => {
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
if (err) return resolve(undefined)
const clientInfo: InfoJWT = {

View File

@@ -20,7 +20,7 @@ export interface GroupResponse {
description: string
}
export interface GroupDetailsResponse {
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 isAdmin -_id'
'id username displayName -_id'
)) as unknown as GroupDetailsResponse
if (!group)
throw {
@@ -249,10 +249,9 @@ const updateUsersListInGroup = async (
message: 'User not found.'
}
const updatedGroup =
action === 'addUser'
? await group.addUser(user)
: await group.removeUser(user)
const updatedGroup = (action === 'addUser'
? await group.addUser(user._id)
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
if (!updatedGroup)
throw {
@@ -261,6 +260,9 @@ 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,

View File

@@ -4,7 +4,6 @@ export * from './code'
export * from './drive'
export * from './group'
export * from './info'
export * from './permission'
export * from './session'
export * from './stp'
export * from './user'

View File

@@ -1,8 +1,4 @@
import { Route, Tags, Example, Get } from 'tsoa'
import { getAuthorizedRoutes } from '../utils'
export interface AuthorizedRoutesResponse {
URIs: string[]
}
export interface InfoResponse {
mode: string
@@ -40,19 +36,4 @@ export class InfoController {
}
return response
}
/**
* @summary Get authorized routes.
*
*/
@Example<AuthorizedRoutesResponse>({
URIs: ['/AppStream', '/SASjsApi/stp/execute']
})
@Get('/authorizedRoutes')
public authorizedRoutes(): AuthorizedRoutesResponse {
const response = {
URIs: getAuthorizedRoutes()
}
return response
}
}

View File

@@ -1,6 +1,10 @@
import path from 'path'
import fs from 'fs'
import { getSessionController, processProgram } from './'
import {
getSASSessionController,
getJSSessionController,
processProgram
} from './'
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types'
import {
@@ -72,7 +76,10 @@ export class ExecutionController {
session: sessionByFileUpload,
runTime
}: ExecuteProgramParams): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
const sessionController = getSessionController(runTime)
const sessionController =
runTime === RunTimeType.SAS
? getSASSessionController()
: getJSSessionController()
const session =
sessionByFileUpload ?? (await sessionController.getSession())

View File

@@ -1,7 +1,7 @@
import { Request, RequestHandler } from 'express'
import multer from 'multer'
import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.'
import { getSASSessionController, getJSSessionController } from '.'
import {
executeProgramRawValidation,
getRunTimeAndFilePath,
@@ -37,23 +37,17 @@ export class FileUploadController {
try {
;({ runTime } = await getRunTimeAndFilePath(programPath))
} catch (err: any) {
return res.status(400).send({
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 sessionController =
runTime === RunTimeType.SAS
? getSASSessionController()
: getJSSessionController()
const session = await sessionController.getSession()
// marking consumed true, so that it's not available

View File

@@ -5,16 +5,14 @@ import { execFile } from 'child_process'
import {
getSessionsFolder,
generateUniqueFileName,
sysInitCompiledPath,
RunTimeType
sysInitCompiledPath
} from '../../utils'
import {
deleteFolder,
createFile,
fileExists,
generateTimestamp,
readFile,
isWindows
readFile
} from '@sasjs/utils'
const execFilePromise = promisify(execFile)
@@ -90,7 +88,7 @@ ${autoExecContent}`
// Additional windows specific options to avoid the desktop popups.
execFilePromise(process.sasLoc!, [
execFilePromise(process.sasLoc, [
'-SYSIN',
codePath,
'-LOG',
@@ -101,9 +99,9 @@ ${autoExecContent}`
session.path,
'-AUTOEXEC',
autoExecPath,
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
isWindows() ? '-nologo' : ''
process.platform === 'win32' ? '-nosplash' : '',
process.platform === 'win32' ? '-icon' : '',
process.platform === 'win32' ? '-nologo' : ''
])
.then(() => {
session.completed = true
@@ -194,21 +192,7 @@ export class JSSessionController extends SessionController {
}
}
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 => {
export const getSASSessionController = (): SASSessionController => {
if (process.sasSessionController) return process.sasSessionController
process.sasSessionController = new SASSessionController()
@@ -216,7 +200,7 @@ const getSASSessionController = (): SASSessionController => {
return process.sasSessionController
}
const getJSSessionController = (): JSSessionController => {
export const getJSSessionController = (): JSSessionController => {
if (process.jsSessionController) return process.jsSessionController
process.jsSessionController = new JSSessionController()

View File

@@ -1,4 +1,3 @@
import { isWindows } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types'
import { generateFileUploadJSCode } from '../../utils'
import { ExecutionVars } from './'
@@ -21,11 +20,11 @@ export const createJSProgram = async (
const preProgramVarStatments = `
let _webout = '';
const weboutPath = '${
isWindows() ? weboutPath.replace(/\\/g, '\\\\') : weboutPath
process.platform === 'win32'
? weboutPath.replace(/\\/g, '\\\\')
: weboutPath
}';
const _sasjs_tokenfile = '${
isWindows() ? tokenFile.replace(/\\/g, '\\\\') : tokenFile
}';
const _sasjs_tokenfile = '${tokenFile}';
const _sasjs_username = '${preProgramVariables?.username}';
const _sasjs_userid = '${preProgramVariables?.userId}';
const _sasjs_displayname = '${preProgramVariables?.displayName}';

View File

@@ -40,7 +40,7 @@ export const processProgram = async (
// waiting for the open event so that we can have underlying file descriptor
await once(writeStream, 'open')
execFileSync(process.nodeLoc!, [codePath], {
execFileSync(process.nodeLoc, [codePath], {
stdio: ['ignore', writeStream, writeStream]
})

View File

@@ -1,331 +0,0 @@
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 })
}

View File

@@ -13,8 +13,7 @@ export class SessionController {
@Example<UserResponse>({
id: 123,
username: 'johnusername',
displayName: 'John',
isAdmin: false
displayName: 'John'
})
@Get('/')
public async session(
@@ -27,6 +26,5 @@ export class SessionController {
const session = (req: express.Request) => ({
id: req.user!.userId,
username: req.user!.username,
displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin
displayName: req.user!.displayName
})

View File

@@ -24,10 +24,9 @@ export interface UserResponse {
id: number
username: string
displayName: string
isAdmin: boolean
}
export interface UserDetailsResponse {
interface UserDetailsResponse {
id: number
displayName: string
username: string
@@ -49,14 +48,12 @@ export class UserController {
{
id: 123,
username: 'johnusername',
displayName: 'John',
isAdmin: false
displayName: 'John'
},
{
id: 456,
username: 'starkusername',
displayName: 'Stark',
isAdmin: true
displayName: 'Stark'
}
])
@Get('/')
@@ -203,7 +200,7 @@ export class UserController {
const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({})
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
.exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {

View File

@@ -49,10 +49,10 @@ export class WebController {
}
/**
* @summary Destroy the session stored in cookies
* @summary Accept a valid username/password
*
*/
@Get('/SASLogon/logout')
@Get('/logout')
public async logout(@Request() req: express.Request) {
return new Promise((resolve) => {
req.session.destroy(() => {
@@ -99,8 +99,7 @@ const login = async (
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
displayName: user.displayName
}
}
}

View File

@@ -1,14 +1,8 @@
import { RequestHandler, Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { csrfProtection } from '../app'
import {
fetchLatestAutoExec,
ModeType,
verifyTokenInDB,
isAuthorizingRoute
} from '../utils'
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils'
import { desktopUser } from './desktop'
import { authorize } from './authorize'
export const authenticateAccessToken: RequestHandler = async (
req,
@@ -21,10 +15,6 @@ 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) {
@@ -34,7 +24,7 @@ export const authenticateAccessToken: RequestHandler = async (
if (user) {
if (user.isActive) {
req.user = user
return csrfProtection(req, res, nextFunction)
return csrfProtection(req, res, next)
} else return res.sendStatus(401)
}
}
@@ -44,8 +34,8 @@ export const authenticateAccessToken: RequestHandler = async (
authenticateToken(
req,
res,
nextFunction,
process.secrets.ACCESS_TOKEN_SECRET,
next,
process.env.ACCESS_TOKEN_SECRET as string,
'accessToken'
)
}
@@ -55,7 +45,7 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
req,
res,
next,
process.secrets.REFRESH_TOKEN_SECRET,
process.env.REFRESH_TOKEN_SECRET as string,
'refreshToken'
)
}
@@ -68,7 +58,7 @@ const authenticateToken = (
tokenType: 'accessToken' | 'refreshToken'
) => {
const { MODE } = process.env
if (MODE === ModeType.Desktop) {
if (MODE?.trim() !== 'server') {
req.user = {
userId: 1234,
clientId: 'desktopModeClientId',

View File

@@ -1,36 +0,0 @@
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)
}

View File

@@ -2,4 +2,3 @@ export * from './authenticateToken'
export * from './desktop'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'
export * from './authorize'

View File

@@ -1,9 +1,8 @@
import { RequestHandler } from 'express'
import { ModeType } from '../utils'
export const verifyAdmin: RequestHandler = (req, res, next) => {
const { MODE } = process.env
if (MODE === ModeType.Desktop) return next()
if (MODE?.trim() !== 'server') return next()
const { user } = req
if (!user?.isAdmin) return res.status(401).send('Admin account required')

View File

@@ -1,45 +0,0 @@
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)

View File

@@ -1,6 +1,5 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User'
import User from './User'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export interface GroupPayload {
@@ -28,9 +27,8 @@ interface IGroupDocument extends GroupPayload, Document {
}
interface IGroup extends IGroupDocument {
addUser(user: IUser): Promise<GroupDetailsResponse>
removeUser(user: IUser): Promise<GroupDetailsResponse>
hasUser(user: IUser): boolean
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
}
interface IGroupModel extends Model<IGroup> {}
@@ -72,31 +70,28 @@ groupSchema.pre('remove', async function () {
})
// Instance Methods
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(
'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()
}
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)
)
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('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',

View File

@@ -1,36 +0,0 @@
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

View File

@@ -35,7 +35,6 @@ export interface UserPayload {
}
interface IUserDocument extends UserPayload, Document {
_id: Schema.Types.ObjectId
id: number
isAdmin: boolean
isActive: boolean
@@ -44,7 +43,7 @@ interface IUserDocument extends UserPayload, Document {
tokens: [{ [key: string]: string }]
}
export interface IUser extends IUserDocument {
interface IUser extends IUserDocument {
comparePassword(password: string): boolean
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>

View File

@@ -17,7 +17,6 @@ import groupRouter from './group'
import clientRouter from './client'
import authRouter from './auth'
import sessionRouter from './session'
import permissionRouter from './permission'
const router = express.Router()
@@ -36,12 +35,6 @@ 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(
'/',

View File

@@ -13,14 +13,4 @@ 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

View File

@@ -1,69 +0,0 @@
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

View File

@@ -29,12 +29,7 @@ jest
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app'
import {
UserController,
PermissionController,
PermissionSetting,
PrincipalType
} from '../../../controllers/'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getFilesFolder } = fileUtilModules
@@ -53,7 +48,6 @@ describe('drive', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
const controller = new UserController()
const permissionController = new PermissionController()
let accessToken: string
@@ -64,31 +58,11 @@ describe('drive', () => {
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user)
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
accessToken = generateAccessToken({
clientId,
userId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
})
afterAll(async () => {
@@ -971,12 +945,3 @@ 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
}

View File

@@ -1,571 +0,0 @@
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({})
}

View File

@@ -4,12 +4,7 @@ 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 { UserController } from '../../../controllers/'
import {
generateAccessToken,
saveTokensInDB,
@@ -46,21 +41,12 @@ describe('stp', () => {
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
})
accessToken = await generateSaveTokenAndCreateUser(user)
})
afterAll(async () => {

View File

@@ -770,14 +770,12 @@ describe('user', () => {
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
displayName: adminUser.displayName
},
{
id: expect.anything(),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
displayName: user.displayName
}
])
})
@@ -798,14 +796,12 @@ describe('user', () => {
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
displayName: adminUser.displayName
},
{
id: expect.anything(),
username: 'randomUser',
displayName: user.displayName,
isAdmin: user.isAdmin
displayName: user.displayName
}
])
})

View File

@@ -79,8 +79,7 @@ describe('web', () => {
expect(res.body.user).toEqual({
id: expect.any(Number),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
displayName: user.displayName
})
})
})

View File

@@ -1,6 +1,5 @@
import path from 'path'
import express, { Request } from 'express'
import { authenticateAccessToken } from '../../middlewares'
import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
@@ -10,7 +9,7 @@ const appStreams: { [key: string]: string } = {}
const router = express.Router()
router.get('/', authenticateAccessToken, async (req, res) => {
router.get('/', async (req, res) => {
const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken())
@@ -67,7 +66,7 @@ export const publishAppStream = async (
return {}
}
router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) {
router.get(`/*`, function (req: Request, res, next) {
const reqPath = req.path.replace(/^\//, '')
// Redirecting to url with trailing slash for appStream base URL only

View File

@@ -48,7 +48,7 @@ webRouter.post(
}
)
webRouter.get('/SASLogon/logout', desktopRestrict, async (req, res) => {
webRouter.get('/logout', desktopRestrict, async (req, res) => {
try {
await controller.logout(req)
res.status(200).send('OK!')

View File

@@ -1,14 +1,12 @@
declare namespace NodeJS {
export interface Process {
sasLoc?: string
nodeLoc?: string
sasLoc: string
nodeLoc: string
driveLoc: string
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
}
}

View File

@@ -5,8 +5,6 @@ import { AppStreamConfig } from '../types'
import { getAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => {
process.appStreamConfig = {}
if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getAppStreamConfigPath()
@@ -23,6 +21,7 @@ export const loadAppStreamConfig = async () => {
} catch (_) {
appStreamConfig = {}
}
process.appStreamConfig = {}
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
const { appLoc, streamWebFolder, streamLogo } = entry

View File

@@ -9,5 +9,7 @@ export const connectDB = async () => {
}
console.log('Connected to DB!')
return seedDB()
await seedDB()
return mongoose.connection
}

View File

@@ -22,8 +22,6 @@ export const getDesktopUserAutoExecPath = () =>
export const getSasjsRootFolder = () => process.driveLoc
export const getLogFolder = () => process.logsLoc
export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
@@ -34,6 +32,8 @@ 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 = () =>

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAccessToken = (data: InfoJWT) =>
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
expiresIn: '1day'
})

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAuthCode = (data: InfoJWT) =>
jwt.sign(data, process.secrets.AUTH_CODE_SECRET, {
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
expiresIn: '30s'
})

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateRefreshToken = (data: InfoJWT) =>
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
expiresIn: '30 days'
})

View File

@@ -1,35 +0,0 @@
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))

View File

@@ -2,32 +2,25 @@ import path from 'path'
import { fileExists, getString, readFile } from '@sasjs/utils'
export const getCertificates = async () => {
const { PRIVATE_KEY, CERT_CHAIN, CA_ROOT } = process.env
let ca
const { PRIVATE_KEY, FULL_CHAIN, CA } = process.env
const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)'))
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
const caPath = CA_ROOT
const certPath = FULL_CHAIN ?? (await getFileInput('Full Chain (PEM)'))
const caPath = CA ?? (await getFileInput('Full Chain (PEM)'))
console.log('keyPath: ', keyPath)
console.log('certPath: ', certPath)
if (caPath) console.log('caPath: ', caPath)
console.log('caPath: ', caPath)
const key = await readFile(keyPath)
const cert = await readFile(certPath)
if (caPath) ca = await readFile(caPath)
const ca = await readFile(caPath)
return { key, cert, ca }
}
const getFileInput = async (
filename: string,
required: boolean = true
): Promise<string> => {
const getFileInput = async (filename: string): 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)))) {

View File

@@ -1,20 +1,15 @@
import path from 'path'
import { getString } from '@sasjs/utils/input'
import { createFolder, fileExists, folderExists, isWindows } from '@sasjs/utils'
import { RunTimeType } from './verifyEnvVariables'
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => {
const { SAS_PATH, NODE_PATH } = process.env
let sasLoc, nodeLoc
if (process.runTimes.includes(RunTimeType.SAS)) {
sasLoc = SAS_PATH ?? (await getSASLocation())
}
if (process.runTimes.includes(RunTimeType.JS)) {
nodeLoc = NODE_PATH ?? (await getNodeLocation())
}
const sasLoc = SAS_PATH ?? (await getSASLocation())
const nodeLoc = NODE_PATH ?? (await getNodeLocation())
// const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
return { sasLoc, nodeLoc }
}

View File

@@ -5,10 +5,14 @@ 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)) {
// if program path is provided with extension we should split that into code path and ext as run time
if (ext) {
const runTime = ext.slice(1)
const runTimeTypes = Object.values(RunTimeType)
if (!runTimeTypes.includes(runTime as RunTimeType)) {
throw `The '${runTime}' runtime is not supported.`
}
const codePath = path
.join(getFilesFolder(), programPath)

View File

@@ -8,7 +8,6 @@ export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getAuthorizedRoutes'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'

View File

@@ -1,73 +1,6 @@
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'
@@ -80,3 +13,23 @@ 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}`)
}
}

View File

@@ -1,33 +1,19 @@
import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
import { getDesktopFields, ModeType, RunTimeType } 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
}
process.runTimes = (RUN_TIMES?.split(',') as RunTimeType[]) ?? []
const { MODE } = process.env
if (MODE === ModeType.Server) {
process.sasLoc = process.env.SAS_PATH
process.nodeLoc = process.env.NODE_PATH
process.sasLoc = process.env.SAS_PATH as string
process.nodeLoc = process.env.NODE_PATH as string
} else {
const { sasLoc, nodeLoc } = await getDesktopFields()
@@ -40,16 +26,10 @@ 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)
const { RUN_TIMES } = process.env
process.runTimes = (RUN_TIMES as string).split(',') as RunTimeType[]
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
console.log('sasLogs: ', process.logsLoc)
console.log('runTimes: ', process.runTimes)
}

View File

@@ -1,4 +1,4 @@
import { extractHeaders } from '../extractHeaders'
import { extractHeaders } from '..'
describe('extractHeaders', () => {
it('should return valid http headers', () => {

View File

@@ -1,4 +1,4 @@
import { parseLogToArray } from '../parseLogToArray'
import { parseLogToArray } from '..'
describe('parseLogToArray', () => {
it('should parse log to array type', () => {

View File

@@ -1,6 +1,6 @@
import path from 'path'
import { MulterFile } from '../types/Upload'
import { listFilesInFolder, readFileBinary, isWindows } from '@sasjs/utils'
import { listFilesInFolder, readFileBinary } from '@sasjs/utils'
interface FilenameMapSingle {
fieldName: string
@@ -118,9 +118,7 @@ export const generateFileUploadJSCode = async (
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_FILEREF${fileCount} = fs.readFileSync('${filePath}')`
uploadCode += `\nconst _WEBIN_FILENAME${fileCount} = '${filesNamesMap[fileName].originalName}'`
uploadCode += `\nconst _WEBIN_NAME${fileCount} = '${filesNamesMap[fileName].fieldName}'`
}

View File

@@ -1,6 +1,5 @@
import Joi from 'joi'
import { PermissionSetting, PrincipalType } from '../controllers/permission'
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
import { RunTimeType } from '.'
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
const passwordSchema = Joi.string().min(6).max(1024)
@@ -87,27 +86,6 @@ 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),

View File

@@ -78,7 +78,33 @@ const verifyMODE = (): string[] => {
}
if (process.env.MODE === ModeType.Server) {
const { DB_CONNECT } = process.env
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}'`
)
if (process.env.NODE_ENV !== 'test')
if (!DB_CONNECT)
@@ -103,16 +129,16 @@ const verifyPROTOCOL = (): string[] => {
}
if (process.env.PROTOCOL === ProtocolType.HTTPS) {
const { PRIVATE_KEY, CERT_CHAIN } = process.env
const { PRIVATE_KEY, FULL_CHAIN } = process.env
if (!PRIVATE_KEY)
errors.push(
`- PRIVATE_KEY is required for PROTOCOL '${ProtocolType.HTTPS}'`
)
if (!CERT_CHAIN)
if (!FULL_CHAIN)
errors.push(
`- CERT_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
`- FULL_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
)
}
@@ -232,5 +258,5 @@ const DEFAULTS = {
PORT: '5000',
HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
RUN_TIMES: RunTimeType.SAS
RUN_TIMES: `${RunTimeType.SAS}`
}

View File

@@ -28,8 +28,7 @@ export const extractJSONFromZip = async (zipFile: Express.Multer.File) => {
for await (const entry of zip) {
const fileName = entry.path as string
// grab the first json found in .zip
if (fileName.toUpperCase().endsWith('.JSON')) {
if (fileName.toUpperCase().endsWith('.JSON') && fileName === fileInZip) {
fileContent = await entry.buffer()
break
} else {

View File

@@ -13,7 +13,7 @@
"tags": [
{
"name": "Info",
"description": "Get Server Information"
"description": "Get Server Info"
},
{
"name": "Session",
@@ -21,11 +21,7 @@
},
{
"name": "User",
"description": "Operations with users"
},
{
"name": "Permission",
"description": "Operations about permissions"
"description": "Operations about users"
},
{
"name": "Client",
@@ -37,19 +33,19 @@
},
{
"name": "Drive",
"description": "Operations on SASjs Drive"
"description": "Operations about drive"
},
{
"name": "Group",
"description": "Operations on groups and group memberships"
"description": "Operations about group"
},
{
"name": "STP",
"description": "Execution of Stored Programs"
"description": "Operations about STP"
},
{
"name": "CODE",
"description": "Execution of code (various runtimes are supported)"
"description": "Operations on SAS code"
},
{
"name": "Web",

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "webpack-dev-server --config webpack.dev.ts --hot",
"build": "webpack --config webpack.prod.ts"
"start": "npx webpack-dev-server --config webpack.dev.ts --hot",
"build": "npx webpack --config webpack.prod.ts"
},
"dependencies": {
"@emotion/react": "^11.4.1",

View File

@@ -1,35 +0,0 @@
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>
)
}

View File

@@ -144,18 +144,6 @@ 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}

View File

@@ -22,7 +22,7 @@ const Login = () => {
username,
password
}).catch((err: any) => {
setErrorMessage(err.response?.data || err.toString())
setErrorMessage(err.response.data)
return {}
})
@@ -30,7 +30,6 @@ const Login = () => {
appContext.setUserId?.(user.id)
appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin)
appContext.setLoggedIn?.(loggedIn)
}
}

View File

@@ -1,43 +0,0 @@
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

View File

@@ -1,62 +0,0 @@
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

View File

@@ -1,214 +0,0 @@
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

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,15 +1,12 @@
import React, { useState, useContext } from 'react'
import * as React 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'
@@ -20,8 +17,7 @@ const StyledTabpanel = styled(TabPanel)({
})
const Settings = () => {
const appContext = useContext(AppContext)
const [value, setValue] = useState('profile')
const [value, setValue] = React.useState('profile')
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
setValue(newValue)
@@ -46,17 +42,11 @@ 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>
)

View File

@@ -1,483 +0,0 @@
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
}

View File

@@ -1,154 +0,0 @@
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

View File

@@ -1,84 +0,0 @@
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

View File

@@ -2,15 +2,13 @@ import React, { useEffect, useRef, useState, useContext } from 'react'
import axios from 'axios'
import {
Backdrop,
Box,
Button,
CircularProgress,
FormControl,
MenuItem,
Paper,
FormControl,
Select,
SelectChangeEvent,
Button,
Paper,
Tab,
Tooltip
} from '@mui/material'
@@ -52,7 +50,6 @@ const Studio = () => {
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))
@@ -81,7 +78,6 @@ const Studio = () => {
const handleRunBtnClick = () => runCode(getSelection() || fileContent)
const runCode = (code: string) => {
setIsRunning(true)
axios
.post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime })
.then((res: any) => {
@@ -98,7 +94,6 @@ const Studio = () => {
window.scrollTo(0, document.body.scrollHeight)
})
.catch((err) => console.log(err))
.finally(() => setIsRunning(false))
}
const handleKeyDown = (event: any) => {
@@ -168,12 +163,6 @@ const Studio = () => {
</Box>
<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}>

View File

@@ -29,8 +29,6 @@ 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
@@ -46,8 +44,6 @@ export const AppContext = createContext<AppContextProps>({
setUsername: null,
displayName: '',
setDisplayName: null,
isAdmin: false,
setIsAdmin: null,
mode: ModeType.Server,
runTimes: [],
logout: null
@@ -60,7 +56,6 @@ 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[]>([])
@@ -75,7 +70,6 @@ const AppContextProvider = (props: { children: ReactNode }) => {
setUserId(data.id)
setUsername(data.username)
setDisplayName(data.displayName)
setIsAdmin(data.isAdmin)
setLoggedIn(true)
})
.catch(() => {
@@ -94,7 +88,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
}, [])
const logout = useCallback(() => {
axios.get('/SASLogon/logout').then(() => {
axios.get('/logout').then(() => {
setLoggedIn(false)
setUsername('')
setDisplayName('')
@@ -113,8 +107,6 @@ const AppContextProvider = (props: { children: ReactNode }) => {
setUsername,
displayName,
setDisplayName,
isAdmin,
setIsAdmin,
mode,
runTimes,
logout

View File

@@ -18,10 +18,3 @@ code {
flex-direction: column;
align-items: center;
}
.permissions-page {
display: flex;
flex-direction: column;
padding: '5px 10px';
margin-top: '10px';
}

View File

@@ -1,32 +0,0 @@
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
}