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

Compare commits

...

36 Commits

Author SHA1 Message Date
semantic-release-bot
cb5be1be21 chore(release): 0.15.1 [skip ci]
## [0.15.1](https://github.com/sasjs/server/compare/v0.15.0...v0.15.1) (2022-08-10)

### Bug Fixes

* **web:** fix UI responsiveness ([d99fdd1](d99fdd1ec7))
2022-08-10 10:34:36 +00:00
Allan Bowe
d90fa9e5dd Merge pull request #251 from sasjs/issue-250
fix(web): fix UI responsiveness
2022-08-10 11:29:41 +01:00
d99fdd1ec7 fix(web): fix UI responsiveness 2022-08-10 15:18:05 +05:00
semantic-release-bot
399b5edad0 chore(release): 0.15.0 [skip ci]
# [0.15.0](https://github.com/sasjs/server/compare/v0.14.1...v0.15.0) (2022-08-05)

### Bug Fixes

* after selecting file in sidebar collapse sidebar in mobile view ([e215958](e215958b8b))
* improve mobile view for studio page ([c67d3ee](c67d3ee2f1))
* improve responsiveness for mobile view ([6ef40b9](6ef40b954a))
* improve user experience for adding permissions ([7a162ed](7a162eda8f))
* show logout button only when user is logged in ([9227cd4](9227cd449d))

### Features

* add multiple permission for same combination of type and principal at once ([754704b](754704bca8))
2022-08-05 09:59:19 +00:00
Allan Bowe
1dbc12e96b Merge pull request #249 from sasjs/issue-225
feat: add multiple permission for same combination of type and principal at once
2022-08-05 10:55:32 +01:00
e215958b8b fix: after selecting file in sidebar collapse sidebar in mobile view 2022-08-05 14:18:59 +05:00
9227cd449d fix: show logout button only when user is logged in 2022-08-05 01:22:27 +05:00
c67d3ee2f1 fix: improve mobile view for studio page 2022-08-05 01:10:15 +05:00
6ef40b954a fix: improve responsiveness for mobile view 2022-08-04 22:57:21 +05:00
semantic-release-bot
0d913baff1 chore(release): 0.14.1 [skip ci]
## [0.14.1](https://github.com/sasjs/server/compare/v0.14.0...v0.14.1) (2022-08-04)

### Bug Fixes

* **apps:** App Stream logo fix ([87c03c5](87c03c5f8d))
* **cookie:** XSRF cookie is removed and passed token in head section ([77f8d30](77f8d30baf))
* **env:** check added for not providing WHITELIST ([5966016](5966016853))
* **web:** show login on logged-out state ([f7fcc77](f7fcc7741a))
2022-08-04 12:10:31 +00:00
Allan Bowe
3671736c3d Merge pull request #248 from sasjs/cookies-management
fix(cookie): XSRF cookie is removed and passed token in head section
2022-08-04 13:06:30 +01:00
34cd84d8a9 chore: improve interface for add permission response 2022-08-04 16:34:15 +05:00
Saad Jutt
f7fcc7741a fix(web): show login on logged-out state 2022-08-04 05:39:28 +05:00
Saad Jutt
18052fdbf6 test: fixed failed specs 2022-08-04 04:01:51 +05:00
Saad Jutt
5966016853 fix(env): check added for not providing WHITELIST 2022-08-04 03:32:04 +05:00
Saad Jutt
87c03c5f8d fix(apps): App Stream logo fix 2022-08-04 03:03:27 +05:00
7a162eda8f fix: improve user experience for adding permissions 2022-08-04 02:51:59 +05:00
754704bca8 feat: add multiple permission for same combination of type and principal at once 2022-08-03 23:26:31 +05:00
Saad Jutt
77f8d30baf fix(cookie): XSRF cookie is removed and passed token in head section 2022-08-03 03:38:11 +05:00
semantic-release-bot
78bea7c154 chore(release): 0.14.0 [skip ci]
# [0.14.0](https://github.com/sasjs/server/compare/v0.13.3...v0.14.0) (2022-08-02)

### Bug Fixes

* add restriction on  add/remove user to public group ([d3a516c](d3a516c36e))
* call jwt.verify in synchronous way ([254bc07](254bc07da7))

### Features

* add public group to DB on seed ([c3e3bef](c3e3befc17))
* bypass authentication when route is enabled for public group ([68515f9](68515f95a6))
2022-08-02 19:08:38 +00:00
Saad Jutt
9c3b155c12 Merge pull request #246 from sasjs/issue-240
feat: bypass authentication when route is enabled for public group
2022-08-03 00:03:43 +05:00
Allan Bowe
98e501334f Update seedDB.ts 2022-08-02 19:33:16 +01:00
Allan Bowe
bbfd53e79e Update group.spec.ts 2022-08-02 19:32:44 +01:00
254bc07da7 fix: call jwt.verify in synchronous way 2022-08-02 23:05:42 +05:00
f978814ca7 chore: code refactor 2022-08-02 22:16:41 +05:00
68515f95a6 feat: bypass authentication when route is enabled for public group 2022-08-02 18:06:33 +05:00
d3a516c36e fix: add restriction on add/remove user to public group 2022-08-02 18:05:28 +05:00
c3e3befc17 feat: add public group to DB on seed 2022-08-02 18:04:00 +05:00
semantic-release-bot
275de9478e chore(release): 0.13.3 [skip ci]
## [0.13.3](https://github.com/sasjs/server/compare/v0.13.2...v0.13.3) (2022-08-02)

### Bug Fixes

* show non-admin user his own permissions only ([8a3054e](8a3054e19a))
* update schema of Permission ([5d5a9d3](5d5a9d3788))
2022-08-02 12:01:53 +00:00
Allan Bowe
1a3ef62cb2 Merge pull request #243 from sasjs/issue-241
fix: show non-admin user his own permissions only
2022-08-02 12:57:57 +01:00
79b7827b7c chore: update tabs label in setting page 2022-08-01 23:01:05 +05:00
37e1aa9b61 chore: spec fixed 2022-08-01 22:54:31 +05:00
7e504008b7 chore: quick fix 2022-08-01 22:50:18 +05:00
5d5a9d3788 fix: update schema of Permission 2022-08-01 21:33:10 +05:00
b7dff341f0 chore: fix specs 2022-07-30 00:18:02 +05:00
8a3054e19a fix: show non-admin user his own permissions only 2022-07-30 00:01:15 +05:00
39 changed files with 1432 additions and 585 deletions

View File

@@ -1,3 +1,58 @@
## [0.15.1](https://github.com/sasjs/server/compare/v0.15.0...v0.15.1) (2022-08-10)
### Bug Fixes
* **web:** fix UI responsiveness ([d99fdd1](https://github.com/sasjs/server/commit/d99fdd1ec7991b94a0d98338d7a7a6216f46ce45))
# [0.15.0](https://github.com/sasjs/server/compare/v0.14.1...v0.15.0) (2022-08-05)
### Bug Fixes
* after selecting file in sidebar collapse sidebar in mobile view ([e215958](https://github.com/sasjs/server/commit/e215958b8b05d7a8ce9d82395e0640b5b37fb40d))
* improve mobile view for studio page ([c67d3ee](https://github.com/sasjs/server/commit/c67d3ee2f102155e2e9781e13d5d33c1ab227cb4))
* improve responsiveness for mobile view ([6ef40b9](https://github.com/sasjs/server/commit/6ef40b954a87ebb0a2621119064f38d58ea85148))
* improve user experience for adding permissions ([7a162ed](https://github.com/sasjs/server/commit/7a162eda8fc60383ff647d93e6611799e2e6af7a))
* show logout button only when user is logged in ([9227cd4](https://github.com/sasjs/server/commit/9227cd449dc46fd960a488eb281804a9b9ffc284))
### Features
* add multiple permission for same combination of type and principal at once ([754704b](https://github.com/sasjs/server/commit/754704bca89ecbdbcc3bd4ef04b94124c4f24167))
## [0.14.1](https://github.com/sasjs/server/compare/v0.14.0...v0.14.1) (2022-08-04)
### Bug Fixes
* **apps:** App Stream logo fix ([87c03c5](https://github.com/sasjs/server/commit/87c03c5f8dbdfc151d4ff3722ecbcd3f7e409aea))
* **cookie:** XSRF cookie is removed and passed token in head section ([77f8d30](https://github.com/sasjs/server/commit/77f8d30baf9b1077279c29f1c3e5ca02a5436bc0))
* **env:** check added for not providing WHITELIST ([5966016](https://github.com/sasjs/server/commit/5966016853369146b27ac5781808cb51d65c887f))
* **web:** show login on logged-out state ([f7fcc77](https://github.com/sasjs/server/commit/f7fcc7741aa2af93a4a2b1e651003704c9bbff0c))
# [0.14.0](https://github.com/sasjs/server/compare/v0.13.3...v0.14.0) (2022-08-02)
### Bug Fixes
* add restriction on add/remove user to public group ([d3a516c](https://github.com/sasjs/server/commit/d3a516c36e45aa1cc76c30c744e6a0e5bd553165))
* call jwt.verify in synchronous way ([254bc07](https://github.com/sasjs/server/commit/254bc07da744a9708109bfb792be70aa3f6284f4))
### Features
* add public group to DB on seed ([c3e3bef](https://github.com/sasjs/server/commit/c3e3befc17102ee1754e1403193040b4f79fb2a7))
* bypass authentication when route is enabled for public group ([68515f9](https://github.com/sasjs/server/commit/68515f95a65d422e29c0ed6028f3ea0ae8d9b1bf))
## [0.13.3](https://github.com/sasjs/server/compare/v0.13.2...v0.13.3) (2022-08-02)
### Bug Fixes
* show non-admin user his own permissions only ([8a3054e](https://github.com/sasjs/server/commit/8a3054e19ade82e2792cfb0f2a8af9e502c5eb52))
* update schema of Permission ([5d5a9d3](https://github.com/sasjs/server/commit/5d5a9d3788281d75c56f68f0dff231abc9c9c275))
## [0.13.2](https://github.com/sasjs/server/compare/v0.13.1...v0.13.2) (2022-08-01)

View File

@@ -470,12 +470,89 @@ components:
additionalProperties: false
AuthorizedRoutesResponse:
properties:
URIs:
paths:
items:
type: string
type: array
required:
- URIs
- paths
type: object
additionalProperties: false
PermissionDetailsResponse:
properties:
permissionId:
type: number
format: double
path:
type: string
type:
type: string
setting:
type: string
user:
$ref: '#/components/schemas/UserResponse'
group:
$ref: '#/components/schemas/GroupDetailsResponse'
required:
- permissionId
- path
- type
- setting
type: object
additionalProperties: false
PermissionType:
enum:
- Route
type: string
PermissionSettingForRoute:
enum:
- Grant
- Deny
type: string
PrincipalType:
enum:
- user
- group
type: string
RegisterPermissionPayload:
properties:
path:
type: string
description: 'Name of affected resource'
example: /SASjsApi/code/execute
type:
$ref: '#/components/schemas/PermissionType'
description: 'Type of affected resource'
example: Route
setting:
$ref: '#/components/schemas/PermissionSettingForRoute'
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:
- path
- type
- setting
- principalType
- principalId
type: object
additionalProperties: false
UpdatePermissionPayload:
properties:
setting:
$ref: '#/components/schemas/PermissionSettingForRoute'
description: 'The indication of whether (and to what extent) access is provided'
example: Grant
required:
- setting
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
@@ -521,71 +598,6 @@ components:
- 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
@@ -1598,12 +1610,165 @@ paths:
$ref: '#/components/schemas/AuthorizedRoutesResponse'
examples:
'Example 1':
value: { URIs: [/AppStream, /SASjsApi/stp/execute] }
summary: 'Get authorized routes.'
value: { paths: [/AppStream, /SASjsApi/stp/execute] }
summary: 'Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature.'
tags:
- Info
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,
path: /SASjsApi/code/execute,
type: Route,
setting: Grant,
user:
{
id: 1,
username: johnSnow01,
displayName: 'John Snow',
isAdmin: false
}
},
{
permissionId: 124,
path: /SASjsApi/code/execute,
type: Route,
setting: Grant,
group:
{
groupId: 1,
name: DCGroup,
description: 'This group represents Data Controller Users',
isActive: true,
users: []
}
}
]
description: "Get the list of permission rules applicable the authenticated user.\nIf the user is an admin, all rules are returned."
summary: 'Get the list of permission rules. If the user is admin, all rules are returned.'
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,
path: /SASjsApi/code/execute,
type: Route,
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,
path: /SASjsApi/code/execute,
type: Route,
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
/SASjsApi/session:
get:
operationId: Session
@@ -1788,154 +1953,6 @@ paths:
- 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:

View File

@@ -1,6 +1,6 @@
import path from 'path'
import express, { ErrorRequestHandler } from 'express'
import csrf from 'csurf'
import csrf, { CookieOptions } from 'csurf'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
@@ -32,9 +32,10 @@ const app = express()
const { PROTOCOL } = process.env
export const cookieOptions = {
export const cookieOptions: CookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true,
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}

View File

@@ -10,7 +10,7 @@ import {
Body
} from 'tsoa'
import Group, { GroupPayload } from '../model/Group'
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import { UserResponse } from './user'
@@ -241,6 +241,13 @@ const updateUsersListInGroup = async (
message: 'Group not found.'
}
if (group.name === PUBLIC_GROUP_NAME)
throw {
code: 400,
status: 'Bad Request',
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
}
const user = await User.findOne({ id: userId })
if (!user)
throw {

View File

@@ -1,7 +1,7 @@
import { Route, Tags, Example, Get } from 'tsoa'
import { getAuthorizedRoutes } from '../utils'
export interface AuthorizedRoutesResponse {
URIs: string[]
paths: string[]
}
export interface InfoResponse {
@@ -42,16 +42,16 @@ export class InfoController {
}
/**
* @summary Get authorized routes.
* @summary Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature.
*
*/
@Example<AuthorizedRoutesResponse>({
URIs: ['/AppStream', '/SASjsApi/stp/execute']
paths: ['/AppStream', '/SASjsApi/stp/execute']
})
@Get('/authorizedRoutes')
public authorizedRoutes(): AuthorizedRoutesResponse {
const response = {
URIs: getAuthorizedRoutes()
paths: getAuthorizedRoutes()
}
return response
}

View File

@@ -1,3 +1,4 @@
import express from 'express'
import {
Security,
Route,
@@ -8,7 +9,8 @@ import {
Post,
Patch,
Delete,
Body
Body,
Request
} from 'tsoa'
import Permission from '../model/Permission'
@@ -17,12 +19,16 @@ import Group from '../model/Group'
import { UserResponse } from './user'
import { GroupDetailsResponse } from './group'
export enum PermissionType {
route = 'Route'
}
export enum PrincipalType {
user = 'user',
group = 'group'
}
export enum PermissionSetting {
export enum PermissionSettingForRoute {
grant = 'Grant',
deny = 'Deny'
}
@@ -32,12 +38,17 @@ interface RegisterPermissionPayload {
* Name of affected resource
* @example "/SASjsApi/code/execute"
*/
uri: string
path: string
/**
* Type of affected resource
* @example "Route"
*/
type: PermissionType
/**
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSetting
setting: PermissionSettingForRoute
/**
* Indicates the type of principal
* @example "user"
@@ -55,12 +66,13 @@ interface UpdatePermissionPayload {
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSetting
setting: PermissionSettingForRoute
}
export interface PermissionDetailsResponse {
permissionId: number
uri: string
path: string
type: string
setting: string
user?: UserResponse
group?: GroupDetailsResponse
@@ -71,13 +83,17 @@ export interface PermissionDetailsResponse {
@Tags('Permission')
export class PermissionController {
/**
* @summary Get list of all permissions (uri, setting and userDetail).
* Get the list of permission rules applicable the authenticated user.
* If the user is an admin, all rules are returned.
*
* @summary Get the list of permission rules. If the user is admin, all rules are returned.
*
*/
@Example<PermissionDetailsResponse[]>([
{
permissionId: 123,
uri: '/SASjsApi/code/execute',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
@@ -88,7 +104,8 @@ export class PermissionController {
},
{
permissionId: 124,
uri: '/SASjsApi/code/execute',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
group: {
groupId: 1,
@@ -100,8 +117,10 @@ export class PermissionController {
}
])
@Get('/')
public async getAllPermissions(): Promise<PermissionDetailsResponse[]> {
return getAllPermissions()
public async getAllPermissions(
@Request() request: express.Request
): Promise<PermissionDetailsResponse[]> {
return getAllPermissions(request)
}
/**
@@ -110,7 +129,8 @@ export class PermissionController {
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uri: '/SASjsApi/code/execute',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
@@ -133,7 +153,8 @@ export class PermissionController {
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uri: '/SASjsApi/code/execute',
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
@@ -161,33 +182,43 @@ export class PermissionController {
}
}
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 }
const getAllPermissions = async (
req: express.Request
): Promise<PermissionDetailsResponse[]> => {
const { user } = req
if (user?.isAdmin) return await Permission.get({})
else {
const permissions: PermissionDetailsResponse[] = []
const dbUser = await User.findOne({ id: user?.userId })
if (!dbUser)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
})) as unknown as PermissionDetailsResponse[]
permissions.push(...(await Permission.get({ user: dbUser._id })))
for (const group of dbUser.groups) {
permissions.push(...(await Permission.get({ group })))
}
return permissions
}
}
const createPermission = async ({
uri,
path,
type,
setting,
principalType,
principalId
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
const permission = new Permission({
uri,
path,
type,
setting
})
@@ -212,7 +243,8 @@ const createPermission = async ({
}
const alreadyExists = await Permission.findOne({
uri,
path,
type,
user: userInDB._id
})
@@ -220,7 +252,8 @@ const createPermission = async ({
throw {
code: 409,
status: 'Conflict',
message: 'Permission already exists with provided URI and User.'
message:
'Permission already exists with provided Path, Type and User.'
}
permission.user = userInDB._id
@@ -243,14 +276,16 @@ const createPermission = async ({
}
const alreadyExists = await Permission.findOne({
uri,
path,
type,
group: groupInDB._id
})
if (alreadyExists)
throw {
code: 409,
status: 'Conflict',
message: 'Permission already exists with provided URI and Group.'
message:
'Permission already exists with provided Path, Type and Group.'
}
permission.group = groupInDB._id
@@ -280,7 +315,8 @@ const createPermission = async ({
return {
permissionId: savedPermission.permissionId,
uri: savedPermission.uri,
path: savedPermission.path,
type: savedPermission.type,
setting: savedPermission.setting,
user,
group
@@ -301,7 +337,8 @@ const updatePermission = async (
.select({
_id: 0,
permissionId: 1,
uri: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })

View File

@@ -5,7 +5,9 @@ import {
fetchLatestAutoExec,
ModeType,
verifyTokenInDB,
isAuthorizingRoute
isAuthorizingRoute,
isPublicRoute,
publicUser
} from '../utils'
import { desktopUser } from './desktop'
import { authorize } from './authorize'
@@ -41,7 +43,7 @@ export const authenticateAccessToken: RequestHandler = async (
return res.sendStatus(401)
}
authenticateToken(
await authenticateToken(
req,
res,
nextFunction,
@@ -50,8 +52,12 @@ export const authenticateAccessToken: RequestHandler = async (
)
}
export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
authenticateToken(
export const authenticateRefreshToken: RequestHandler = async (
req,
res,
next
) => {
await authenticateToken(
req,
res,
next,
@@ -60,7 +66,7 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
)
}
const authenticateToken = (
const authenticateToken = async (
req: Request,
res: Response,
next: NextFunction,
@@ -83,12 +89,12 @@ const authenticateToken = (
const authHeader = req.headers['authorization']
const token = authHeader?.split(' ')[1]
if (!token) return res.sendStatus(401)
jwt.verify(token, key, async (err: any, data: any) => {
if (err) return res.sendStatus(401)
try {
if (!token) throw 'Unauthorized'
const data: any = jwt.verify(token, key)
// verify this valid token's entry in DB
const user = await verifyTokenInDB(
data?.userId,
data?.clientId,
@@ -101,8 +107,16 @@ const authenticateToken = (
req.user = user
if (tokenType === 'accessToken') req.accessToken = token
return next()
} else return res.sendStatus(401)
} else throw 'Unauthorized'
}
return res.sendStatus(401)
})
throw 'Unauthorized'
} catch (error) {
if (await isPublicRoute(req)) {
req.user = publicUser
return next()
}
res.sendStatus(401)
}
}

View File

@@ -1,8 +1,11 @@
import { RequestHandler } from 'express'
import User from '../model/User'
import Permission from '../model/Permission'
import { PermissionSetting } from '../controllers/permission'
import { getUri } from '../utils'
import {
PermissionSettingForRoute,
PermissionType
} from '../controllers/permission'
import { getPath, isPublicRoute } from '../utils'
export const authorize: RequestHandler = async (req, res, next) => {
const { user } = req
@@ -14,23 +17,35 @@ export const authorize: RequestHandler = async (req, res, next) => {
// no need to check for permissions when user is admin
if (user.isAdmin) return next()
// no need to check for permissions when route is Public
if (await isPublicRoute(req)) return next()
const dbUser = await User.findOne({ id: user.userId })
if (!dbUser) return res.sendStatus(401)
const uri = getUri(req)
const path = getPath(req)
// find permission w.r.t user
const permission = await Permission.findOne({ uri, user: dbUser._id })
const permission = await Permission.findOne({
path,
type: PermissionType.route,
user: dbUser._id
})
if (permission) {
if (permission.setting === PermissionSetting.grant) return next()
if (permission.setting === PermissionSettingForRoute.grant) return next()
else return res.sendStatus(401)
}
// find permission w.r.t user's groups
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({ uri, group })
if (groupPermission?.setting === PermissionSetting.grant) return next()
const groupPermission = await Permission.findOne({
path,
type: PermissionType.route,
group
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next()
}
return res.sendStatus(401)
}

View File

@@ -3,6 +3,8 @@ import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export const PUBLIC_GROUP_NAME = 'Public'
export interface GroupPayload {
/**
* Name of the group

View File

@@ -1,8 +1,15 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
import { PermissionDetailsResponse } from '../controllers'
interface GetPermissionBy {
user?: Schema.Types.ObjectId
group?: Schema.Types.ObjectId
}
interface IPermissionDocument extends Document {
uri: string
path: string
type: string
setting: string
permissionId: number
user: Schema.Types.ObjectId
@@ -11,10 +18,16 @@ interface IPermissionDocument extends Document {
interface IPermission extends IPermissionDocument {}
interface IPermissionModel extends Model<IPermission> {}
interface IPermissionModel extends Model<IPermission> {
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
}
const permissionSchema = new Schema<IPermissionDocument>({
uri: {
path: {
type: String,
required: true
},
type: {
type: String,
required: true
},
@@ -28,6 +41,30 @@ const permissionSchema = new Schema<IPermissionDocument>({
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
// Static Methods
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
PermissionDetailsResponse[]
> {
return (await this.find(getBy)
.select({
_id: 0,
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({
path: 'group',
select: 'groupId name description -_id',
populate: {
path: 'users',
select: 'id username displayName isAdmin -_id',
options: { limit: 15 }
}
})) as unknown as PermissionDetailsResponse[]
})
export const Permission: IPermissionModel = model<
IPermission,
IPermissionModel

View File

@@ -11,7 +11,7 @@ const controller = new PermissionController()
permissionRouter.get('/', async (req, res) => {
try {
const response = await controller.getAllPermissions()
const response = await controller.getAllPermissions(req)
res.send(response)
} catch (err: any) {
const statusCode = err.code

View File

@@ -32,7 +32,8 @@ import appPromise from '../../../app'
import {
UserController,
PermissionController,
PermissionSetting,
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
@@ -48,6 +49,12 @@ const user = {
isActive: true
}
const permission = {
type: PermissionType.route,
principalType: PrincipalType.user,
setting: PermissionSettingForRoute.grant
}
describe('drive', () => {
let app: Express
let con: Mongoose
@@ -66,34 +73,29 @@ describe('drive', () => {
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
...permission,
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/deploy/upload',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
...permission,
path: '/SASjsApi/drive/deploy/upload',
principalId: dbUser.id
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/file',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
...permission,
path: '/SASjsApi/drive/file',
principalId: dbUser.id
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/folder',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
...permission,
path: '/SASjsApi/drive/folder',
principalId: dbUser.id
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/rename',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
...permission,
path: '/SASjsApi/drive/rename',
principalId: dbUser.id
})
})

View File

@@ -5,6 +5,7 @@ import request from 'supertest'
import appPromise from '../../../app'
import { UserController, GroupController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { PUBLIC_GROUP_NAME } from '../../../model/Group'
const clientId = 'someclientID'
const adminUser = {
@@ -27,6 +28,12 @@ const group = {
description: 'DC group for testing purposes.'
}
const PUBLIC_GROUP = {
name: PUBLIC_GROUP_NAME,
description:
'A special group that can be used to bypass authentication for particular routes.'
}
const userController = new UserController()
const groupController = new GroupController()
@@ -535,6 +542,24 @@ describe('group', () => {
expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request when adding user to Public group', async () => {
const dbGroup = await groupController.createGroup(PUBLIC_GROUP)
const dbUser = await userController.createUser({
...user,
username: 'publicUser'
})
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(
`Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
)
})
})
describe('RemoveUser', () => {

View File

@@ -7,10 +7,10 @@ import {
DriveController,
UserController,
GroupController,
ClientController,
PermissionController,
PrincipalType,
PermissionSetting
PermissionType,
PermissionSettingForRoute
} from '../../../controllers/'
import {
UserDetailsResponse,
@@ -56,10 +56,10 @@ const user = {
}
const permission = {
uri: '/SASjsApi/code/execute',
setting: PermissionSetting.grant,
principalType: PrincipalType.user,
principalId: 123
path: '/SASjsApi/code/execute',
type: PermissionType.route,
setting: PermissionSettingForRoute.grant,
principalType: PrincipalType.user
}
const group = {
@@ -69,7 +69,6 @@ const group = {
const userController = new UserController()
const groupController = new GroupController()
const clientController = new ClientController()
const permissionController = new PermissionController()
describe('permission', () => {
@@ -108,7 +107,8 @@ describe('permission', () => {
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uri).toEqual(permission.uri)
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.user).toBeTruthy()
})
@@ -127,7 +127,8 @@ describe('permission', () => {
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uri).toEqual(permission.uri)
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.group).toBeTruthy()
})
@@ -142,53 +143,74 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account even if user has permission', async () => {
it('should respond with Unauthorized if access token is not of an admin account', 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()
.send(permission)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if uri is missing', async () => {
it('should respond with Bad Request if path is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
uri: undefined
path: undefined
})
.expect(400)
expect(res.text).toEqual(`"uri" is required`)
expect(res.text).toEqual(`"path" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if uri is not valid', async () => {
it('should respond with Bad Request if path is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
uri: '/some/random/api/endpoint'
path: '/some/random/api/endpoint'
})
.expect(400)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if type is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
type: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"type" must be [Route]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if type is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
type: undefined
})
.expect(400)
expect(res.text).toEqual(`"type" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
@@ -203,6 +225,20 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalType is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
@@ -217,20 +253,6 @@ describe('permission', () => {
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')
@@ -245,17 +267,17 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
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,
setting: 'invalid'
principalId: undefined
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.text).toEqual(`"principalId" is required`)
expect(res.body).toEqual({})
})
@@ -313,7 +335,8 @@ describe('permission', () => {
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'group'
principalType: 'group',
principalId: 123
})
.expect(404)
@@ -334,7 +357,7 @@ describe('permission', () => {
.expect(409)
expect(res.text).toEqual(
'Permission already exists with provided URI and User.'
'Permission already exists with provided Path, Type and User.'
)
expect(res.body).toEqual({})
})
@@ -357,7 +380,7 @@ describe('permission', () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ setting: 'Deny' })
.send({ setting: PermissionSettingForRoute.deny })
.expect(200)
expect(res.body.setting).toEqual('Deny')
@@ -366,7 +389,7 @@ describe('permission', () => {
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.send(permission)
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
@@ -400,12 +423,11 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
it('should respond with Bad Request if setting is invalid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: 'invalid'
})
.expect(400)
@@ -414,12 +436,12 @@ describe('permission', () => {
expect(res.body).toEqual({})
})
it('should respond with not found (404) if permission with provided id does not exists', async () => {
it('should respond with not found (404) if permission with provided id does not exist', async () => {
const res = await request(app)
.patch('/SASjsApi/permission/123')
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: PermissionSetting.deny
setting: PermissionSettingForRoute.deny
})
.expect(404)
@@ -458,12 +480,12 @@ describe('permission', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
uri: '/test-1',
path: '/test-1',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
uri: '/test-2',
path: '/test-2',
principalId: dbUser.id
})
})
@@ -478,34 +500,37 @@ describe('permission', () => {
expect(res.body).toHaveLength(2)
})
it('should give a list of all permissions when user is not admin', async () => {
const dbUser = await userController.createUser({
it(`should give a list of user's own permissions when user is not admin`, async () => {
const nonAdminUser = await userController.createUser({
...user,
username: 'get' + user.username
})
const accessToken = await generateAndSaveToken(dbUser.id)
const accessToken = await generateAndSaveToken(nonAdminUser.id)
await permissionController.createPermission({
uri: '/SASjsApi/permission',
path: '/test-1',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
principalId: nonAdminUser.id,
setting: PermissionSettingForRoute.grant
})
const permissionCount = 1
const res = await request(app)
.get('/SASjsApi/permission/')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveLength(3)
expect(res.body).toHaveLength(permissionCount)
})
})
describe.only('verify', () => {
describe('verify', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
uri: '/SASjsApi/drive/deploy',
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
})
})

View File

@@ -7,7 +7,8 @@ import appPromise from '../../../app'
import {
UserController,
PermissionController,
PermissionSetting,
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../../../controllers/'
import {
@@ -56,10 +57,11 @@ describe('stp', () => {
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
uri: '/SASjsApi/stp/execute',
path: '/SASjsApi/stp/execute',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
setting: PermissionSettingForRoute.grant
})
})

View File

@@ -39,12 +39,11 @@ describe('web', () => {
describe('home', () => {
it('should respond with CSRF Token', async () => {
await request(app)
.get('/')
.expect(
'set-cookie',
/_csrf=.*; Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=.*; Path=\//
)
const res = await request(app).get('/').expect(200)
expect(res.text).toMatch(
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/
)
})
})
@@ -154,10 +153,10 @@ describe('web', () => {
const getCSRF = async (app: Express) => {
// make request to get CSRF
const { header } = await request(app).get('/')
const { header, text } = await request(app).get('/')
const cookies = header['set-cookie'].join()
const csrfToken = extractCSRF(cookies)
const csrfToken = extractCSRF(text)
return { csrfToken, cookies }
}
@@ -177,7 +176,7 @@ const performLogin = async (
return { cookies: newCookies }
}
const extractCSRF = (cookies: string) =>
/_csrf=(.*); Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=(.*); Path=\//.exec(
cookies
)![2]
const extractCSRF = (text: string) =>
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
text
)![1]

View File

@@ -26,6 +26,7 @@ export const style = `<style>
}
.app-container .app img{
width: 100%;
height: calc(100% - 30px);
margin-bottom: 10px;
border-radius: 10px;
}

View File

@@ -11,11 +11,15 @@ webRouter.get('/', async (req, res) => {
try {
response = await controller.home()
} catch (_) {
response = 'Web Build is not present'
response = '<html><head></head><body>Web Build is not present</body></html>'
} finally {
res.cookie('XSRF-TOKEN', req.csrfToken())
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${req.csrfToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
const injectedContent = response?.replace(
'</head>',
`${codeToInject}</head>`
)
return res.send(response)
return res.send(injectedContent)
}
})

View File

@@ -9,8 +9,7 @@ const StaticAuthorizedRoutes = [
'/SASjsApi/drive/file',
'/SASjsApi/drive/folder',
'/SASjsApi/drive/fileTree',
'/SASjsApi/drive/rename',
'/SASjsApi/permission'
'/SASjsApi/drive/rename'
]
export const getAuthorizedRoutes = () => {
@@ -19,7 +18,7 @@ export const getAuthorizedRoutes = () => {
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
}
export const getUri = (req: Request) => {
export const getPath = (req: Request) => {
const { baseUrl, path: reqPath } = req
if (baseUrl === '/AppStream') {
@@ -33,4 +32,4 @@ export const getUri = (req: Request) => {
}
export const isAuthorizingRoute = (req: Request): boolean =>
getAuthorizedRoutes().includes(getUri(req))
getAuthorizedRoutes().includes(getPath(req))

View File

@@ -16,6 +16,7 @@ export * from './getRunTimeAndFilePath'
export * from './getServerUrl'
export * from './instantiateLogger'
export * from './isDebugOn'
export * from './isPublicRoute'
export * from './zipped'
export * from './parseLogToArray'
export * from './removeTokensInDB'

View File

@@ -0,0 +1,31 @@
import { Request } from 'express'
import { getPath } from './getAuthorizedRoutes'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import Permission from '../model/Permission'
import { PermissionSettingForRoute } from '../controllers'
import { RequestUser } from '../types'
export const isPublicRoute = async (req: Request): Promise<boolean> => {
const group = await Group.findOne({ name: PUBLIC_GROUP_NAME })
if (group) {
const path = getPath(req)
const groupPermission = await Permission.findOne({
path,
group: group?._id
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return true
}
return false
}
export const publicUser: RequestUser = {
userId: 0,
clientId: 'public_app',
username: 'publicUser',
displayName: 'Public User',
isAdmin: false,
isActive: true
}

View File

@@ -1,5 +1,5 @@
import Client from '../model/Client'
import Group from '../model/Group'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import Configuration, { ConfigurationType } from '../model/Configuration'
@@ -31,6 +31,15 @@ export const seedDB = async (): Promise<ConfigurationType> => {
console.log(`DB Seed - Group created: ${GROUP.name}`)
}
// Checking if 'Public' Group is already in the database
const publicGroupExist = await Group.findOne({ name: PUBLIC_GROUP.name })
if (!publicGroupExist) {
const group = new Group(PUBLIC_GROUP)
await group.save()
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
}
// Checking if user is already in the database
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
@@ -68,6 +77,13 @@ const GROUP = {
name: 'AllUsers',
description: 'Group contains all users'
}
const PUBLIC_GROUP = {
name: PUBLIC_GROUP_NAME,
description:
'A special group that can be used to bypass authentication for particular routes.'
}
const CLIENT = {
clientId: 'clientID1',
clientSecret: 'clientSecret'

View File

@@ -1,5 +1,9 @@
import Joi from 'joi'
import { PermissionSetting, PrincipalType } from '../controllers/permission'
import {
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../controllers/permission'
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
@@ -89,12 +93,15 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
uri: Joi.string()
path: Joi.string()
.required()
.valid(...getAuthorizedRoutes()),
type: Joi.string()
.required()
.valid(...Object.values(PermissionType)),
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSetting)),
.valid(...Object.values(PermissionSettingForRoute)),
principalType: Joi.string()
.required()
.valid(...Object.values(PrincipalType)),
@@ -105,7 +112,7 @@ export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSetting))
.valid(...Object.values(PermissionSettingForRoute))
}).validate(data)
export const deployValidation = (data: any): Joi.ValidationResult =>

View File

@@ -125,8 +125,27 @@ const verifyCORS = (): string[] => {
if (CORS) {
const corsTypes = Object.values(CorsType)
if (!corsTypes.includes(CORS as CorsType))
errors.push(`- CORS '${CORS}'\n - valid options ${corsTypes}`)
if (CORS === CorsType.ENABLED) {
const { WHITELIST } = process.env
const urls = WHITELIST?.trim()
.split(' ')
.filter((url) => !!url)
if (urls?.length) {
urls.forEach((url) => {
if (!url.startsWith('http://') && !url.startsWith('https://'))
errors.push(
`- CORS '${CORS}'\n - provided WHITELIST ${url} is not valid`
)
})
} else {
errors.push(`- CORS '${CORS}'\n - provide at least one WHITELIST URL`)
}
}
} else {
const { MODE } = process.env
process.env.CORS =

View File

@@ -22,7 +22,7 @@ function App() {
<HashRouter>
<Header />
<Routes>
<Route path="/" element={<Login />} />
<Route path="*" element={<Login />} />
</Routes>
</HashRouter>
</ThemeProvider>

View File

@@ -2,16 +2,18 @@ import React, { useState, useEffect, useContext } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import {
Box,
AppBar,
Toolbar,
Tabs,
Tab,
Button,
Menu,
MenuItem
MenuItem,
IconButton,
Typography
} from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import SettingsIcon from '@mui/icons-material/Settings'
import { OpenInNew, Settings, Menu as MenuIcon } from '@mui/icons-material'
import Username from './username'
import { AppContext } from '../context/appContext'
@@ -30,31 +32,38 @@ const Header = (props: any) => {
const [tabValue, setTabValue] = useState(
validTabs.includes(pathname) ? pathname : '/'
)
const [anchorEl, setAnchorEl] = useState<
(EventTarget & HTMLButtonElement) | null
>(null)
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null)
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(
null
)
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget)
}
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget)
}
const handleCloseNavMenu = () => {
setAnchorElNav(null)
}
const handleCloseUserMenu = () => {
setAnchorElUser(null)
}
useEffect(() => {
setTabValue(validTabs.includes(pathname) ? pathname : '/')
}, [pathname])
const handleMenu = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleTabChange = (event: React.SyntheticEvent, value: string) => {
setTabValue(value)
}
const handleLogout = () => {
if (appContext.logout) {
handleClose()
handleCloseUserMenu()
appContext.logout()
}
}
@@ -64,43 +73,129 @@ const Header = (props: any) => {
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar variant="dense">
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
navigate('/')
}}
/>
<Tabs
indicatorColor="secondary"
value={tabValue}
onChange={handleTabChange}
>
<Tab label="Home" value="/" to="/" component={Link} />
<Tab
label="Studio"
value="/SASjsStudio"
to="/SASjsStudio"
component={Link}
<Box sx={{ display: { xs: 'none', md: 'flex' } }}>
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
height: '35px',
marginTop: '9px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
navigate('/')
}}
/>
</Tabs>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
>
Apps
</Button>
<Tabs
indicatorColor="secondary"
value={tabValue}
onChange={handleTabChange}
>
<Tab label="Home" value="/" to="/" component={Link} />
<Tab
label="Studio"
value="/SASjsStudio"
to="/SASjsStudio"
component={Link}
/>
</Tabs>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNew />}
>
Apps
</Button>
</Box>
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
<IconButton size="large" onClick={handleOpenNavMenu} color="inherit">
<MenuIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
open={!!anchorElNav}
onClose={handleCloseNavMenu}
sx={{
display: { xs: 'block', md: 'none' }
}}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
>
Home
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsStudio"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
>
Studio
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
endIcon={<OpenInNew />}
>
Apps
</Button>
</MenuItem>
</Menu>
</Box>
<Box sx={{ display: { xs: 'flex', md: 'none' } }}>
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
height: '35px',
marginTop: '2px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
navigate('/')
}}
/>
</Box>
<div
style={{
display: 'flex',
@@ -110,11 +205,11 @@ const Header = (props: any) => {
>
<Username
username={appContext.displayName || appContext.username}
onClickHandler={handleMenu}
onClickHandler={handleOpenUserMenu}
/>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
@@ -124,17 +219,30 @@ const Header = (props: any) => {
vertical: 'top',
horizontal: 'center'
}}
open={!!anchorEl}
onClose={handleClose}
open={!!anchorElUser}
onClose={handleCloseUserMenu}
>
{appContext.loggedIn && (
<MenuItem
sx={{ justifyContent: 'center', display: { md: 'none' } }}
>
<Typography
variant="h5"
sx={{ border: '1px solid black', padding: '5px' }}
>
{appContext.displayName || appContext.username}
</Typography>
</MenuItem>
)}
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsSettings"
onClick={handleClose}
onClick={handleCloseUserMenu}
variant="contained"
color="primary"
startIcon={<SettingsIcon />}
startIcon={<Settings />}
>
Settings
</Button>
@@ -147,7 +255,7 @@ const Header = (props: any) => {
variant="contained"
size="large"
color="primary"
endIcon={<OpenInNewIcon />}
endIcon={<OpenInNew />}
>
Docs
</Button>
@@ -160,16 +268,21 @@ const Header = (props: any) => {
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
endIcon={<OpenInNew />}
>
API
</Button>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ justifyContent: 'center' }}>
<Button variant="contained" color="primary">
Logout
</Button>
</MenuItem>
{appContext.loggedIn && (
<MenuItem
onClick={handleLogout}
sx={{ justifyContent: 'center' }}
>
<Button variant="contained" color="primary">
Logout
</Button>
</MenuItem>
)}
</Menu>
</div>
</Toolbar>

View File

@@ -20,7 +20,14 @@ const Username = (props: any) => {
) : (
<AccountCircle></AccountCircle>
)}
<Typography variant="h6" sx={{ color: 'white', padding: '0 8px' }}>
<Typography
variant="h6"
sx={{
color: 'white',
padding: '0 8px',
display: { xs: 'none', md: 'flex' }
}}
>
{props.username}
</Typography>
</IconButton>

View File

@@ -32,7 +32,13 @@ const BootstrapDialog = styled(Dialog)(({ theme }) => ({
type AddPermissionModalProps = {
open: boolean
handleOpen: Dispatch<SetStateAction<boolean>>
addPermission: (addPermissionPayload: RegisterPermissionPayload) => void
addPermission: (
permissions: RegisterPermissionPayload[],
permissionType: string,
principalType: string,
principal: string,
permissionSetting: string
) => void
}
const AddPermissionModal = ({
@@ -40,10 +46,11 @@ const AddPermissionModal = ({
handleOpen,
addPermission
}: AddPermissionModalProps) => {
const [URIs, setURIs] = useState<string[]>([])
const [loadingURIs, setLoadingURIs] = useState(false)
const [uri, setUri] = useState<string>()
const [principalType, setPrincipalType] = useState('user')
const [paths, setPaths] = useState<string[]>([])
const [loadingPaths, setLoadingPaths] = useState(false)
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
const [permissionType, setPermissionType] = useState('Route')
const [principalType, setPrincipalType] = useState('Group')
const [userPrincipal, setUserPrincipal] = useState<UserResponse>()
const [groupPrincipal, setGroupPrincipal] = useState<GroupResponse>()
const [permissionSetting, setPermissionSetting] = useState('Grant')
@@ -52,29 +59,29 @@ const AddPermissionModal = ({
const [groupPrincipals, setGroupPrincipals] = useState<GroupResponse[]>([])
useEffect(() => {
setLoadingURIs(true)
setLoadingPaths(true)
axios
.get('/SASjsApi/info/authorizedRoutes')
.then((res: any) => {
if (res.data) {
setURIs(res.data.URIs)
setPaths(res.data.paths)
}
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setLoadingURIs(false)
setLoadingPaths(false)
})
}, [])
useEffect(() => {
setLoadingPrincipals(true)
axios
.get(`/SASjsApi/${principalType}`)
.get(`/SASjsApi/${principalType.toLowerCase()}`)
.then((res: any) => {
if (res.data) {
if (principalType === 'user') {
if (principalType.toLowerCase() === 'user') {
const users: UserResponse[] = res.data
const nonAdminUsers = users.filter((user) => !user.isAdmin)
setUserPrincipals(nonAdminUsers)
@@ -92,21 +99,40 @@ const AddPermissionModal = ({
}, [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 permissions: RegisterPermissionPayload[] = []
selectedPaths.forEach((path) => {
const addPermissionPayload: any = {
path,
type: permissionType,
setting: permissionSetting,
principalType: principalType.toLowerCase(),
principalId:
principalType.toLowerCase() === 'user'
? userPrincipal?.id
: groupPrincipal?.groupId
}
permissions.push(addPermissionPayload)
})
const principal =
principalType.toLowerCase() === 'user'
? userPrincipal?.username
: groupPrincipal?.name
addPermission(
permissions,
permissionType,
principalType,
principal!,
permissionSetting
)
}
const addButtonDisabled =
!uri || (principalType === 'user' ? !userPrincipal : !groupPrincipal)
!selectedPaths.length ||
(principalType.toLowerCase() === 'user' ? !userPrincipal : !groupPrincipal)
return (
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
@@ -120,22 +146,37 @@ const AddPermissionModal = ({
<Grid container spacing={2}>
<Grid item xs={12}>
<Autocomplete
options={URIs}
multiple
disableClearable
value={uri}
onChange={(event: any, newValue: string) => setUri(newValue)}
options={paths}
filterSelectedOptions
value={selectedPaths}
onChange={(event: any, newValue: string[]) => {
setSelectedPaths(newValue)
}}
renderInput={(params) => <TextField {...params} label="Paths" />}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={['Route']}
disableClearable
value={permissionType}
onChange={(event: any, newValue: string) =>
setPermissionType(newValue)
}
renderInput={(params) =>
loadingURIs ? (
loadingPaths ? (
<CircularProgress />
) : (
<TextField {...params} label="Principal" />
<TextField {...params} label="Permission Type" />
)
}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={['user', 'group']}
options={['Group', 'User']}
disableClearable
value={principalType}
onChange={(event: any, newValue: string) =>
@@ -147,7 +188,7 @@ const AddPermissionModal = ({
/>
</Grid>
<Grid item xs={12}>
{principalType === 'user' ? (
{principalType.toLowerCase() === 'user' ? (
<Autocomplete
options={userPrincipals}
getOptionLabel={(option) => option.displayName}

View File

@@ -0,0 +1,120 @@
import React from 'react'
import { Typography, DialogContent } from '@mui/material'
import { BootstrapDialog } from '../../components/modal'
import { BootstrapDialogTitle } from '../../components/dialogTitle'
import { PermissionResponse } from '../../utils/types'
export interface PermissionResponsePayload {
permissionType: string
principalType: string
principal: string
permissionSetting: string
existingPermissions: PermissionResponse[]
newAddedPermissions: PermissionResponse[]
updatedPermissions: PermissionResponse[]
errorPaths: string[]
}
type Props = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
payload: PermissionResponsePayload
}
const PermissionResponseModal = ({ open, setOpen, payload }: Props) => {
const newAddedPermissionsLength = payload.newAddedPermissions.length
const updatedPermissionsLength = payload.updatedPermissions.length
const existingPermissionsLength = payload.existingPermissions.length
const appliedPermissionsLength =
newAddedPermissionsLength + updatedPermissionsLength
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle
id="permission-response-modal"
handleOpen={setOpen}
>
Permission Response
</BootstrapDialogTitle>
<DialogContent dividers>
<Typography sx={{ fontWeight: 'bold', marginBottom: '15px' }}>
{`${appliedPermissionsLength} "${payload.permissionSetting}", "${
payload.permissionType
}", "${payload.principalType}", "${payload.principal}" ${
appliedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Applied:
</Typography>
{newAddedPermissionsLength > 0 && (
<>
<Typography>
{`${newAddedPermissionsLength} ${
newAddedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Added:
</Typography>
<ul>
{payload.newAddedPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{updatedPermissionsLength > 0 && (
<>
<Typography>
{` ${updatedPermissionsLength} ${
updatedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Updated:
</Typography>
<ul>
{payload.updatedPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{existingPermissionsLength > 0 && (
<>
<Typography>
{`${existingPermissionsLength} ${
existingPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Unchanged:
</Typography>
<ul>
{payload.existingPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{payload.errorPaths.length > 0 && (
<>
<Typography style={{ color: 'red', marginTop: '10px' }}>
Errors occurred for following paths:
</Typography>
<ul>
{payload.errorPaths.map((path, index) => (
<li key={index}>
<Typography>{path}</Typography>
</li>
))}
</ul>
</>
)}
</DialogContent>
</BootstrapDialog>
</div>
)
}
export default PermissionResponseModal

View File

@@ -31,11 +31,20 @@ const Settings = () => {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
marginTop: '65px'
}}
>
<TabContext value={value}>
<Box component={Paper} sx={{ margin: '0 5px', height: '92vh' }}>
<Box
component={Paper}
sx={{
margin: '0 5px',
height: { md: '92vh' },
display: 'flex',
justifyContent: 'center'
}}
>
<TabList
TabIndicatorProps={{
style: {
@@ -47,7 +56,7 @@ const Settings = () => {
>
<StyledTab label="Profile" value="profile" />
{appContext.mode === ModeType.Server && (
<StyledTab label="Uri Access" value="permission" />
<StyledTab label="Permissions" value="permission" />
)}
</TabList>
</Box>

View File

@@ -27,6 +27,9 @@ import { styled } from '@mui/material/styles'
import Modal from '../../components/modal'
import PermissionFilterModal from './permissionFilterModal'
import AddPermissionModal from './addPermissionModal'
import PermissionResponseModal, {
PermissionResponsePayload
} from './addPermissionResponseModal'
import UpdatePermissionModal from './updatePermissionModal'
import DeleteConfirmationModal from '../../components/deleteConfirmationModal'
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
@@ -36,12 +39,23 @@ import {
PermissionResponse,
RegisterPermissionPayload
} from '../../utils/types'
import {
findExistingPermission,
findUpdatingPermission
} from '../../utils/helper'
import { AppContext } from '../../context/appContext'
const BootstrapTableCell = styled(TableCell)({
textAlign: 'left'
})
const BootstrapGridItem = styled(Grid)({
'&.MuiGrid-item': {
maxWidth: '100%'
}
})
export enum PrincipalType {
User = 'User',
Group = 'Group'
@@ -59,6 +73,20 @@ const Permission = () => {
AlertSeverityType.Success
)
const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false)
const [openPermissionResponseModal, setOpenPermissionResponseModal] =
useState(false)
const [permissionResponsePayload, setPermissionResponsePayload] =
useState<PermissionResponsePayload>({
permissionType: '',
principalType: '',
principal: '',
permissionSetting: '',
existingPermissions: [],
newAddedPermissions: [],
updatedPermissions: [],
errorPaths: []
})
const [updatePermissionModalOpen, setUpdatePermissionModalOpen] =
useState(false)
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
@@ -68,7 +96,7 @@ const Permission = () => {
const [selectedPermission, setSelectedPermission] =
useState<PermissionResponse>()
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [uriFilter, setUriFilter] = useState<string[]>([])
const [pathFilter, setPathFilter] = useState<string[]>([])
const [principalFilter, setPrincipalFilter] = useState<string[]>([])
const [principalTypeFilter, setPrincipalTypeFilter] = useState<
PrincipalType[]
@@ -111,8 +139,10 @@ const Permission = () => {
setFilterModalOpen(false)
const uriFilteredPermissions =
uriFilter.length > 0
? permissions.filter((permission) => uriFilter.includes(permission.uri))
pathFilter.length > 0
? permissions.filter((permission) =>
pathFilter.includes(permission.path)
)
: permissions
const principalFilteredPermissions =
@@ -172,36 +202,84 @@ const Permission = () => {
const resetFilter = () => {
setFilterModalOpen(false)
setUriFilter([])
setPathFilter([])
setPrincipalFilter([])
setSettingFilter([])
setFilteredPermissions([])
setFilterApplied(false)
}
const addPermission = (addPermissionPayload: RegisterPermissionPayload) => {
const addPermission = async (
permissionsToAdd: RegisterPermissionPayload[],
permissionType: string,
principalType: string,
principal: string,
permissionSetting: string
) => {
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 newAddedPermissions: PermissionResponse[] = []
const updatedPermissions: PermissionResponse[] = []
const errorPaths: string[] = []
const existingPermissions: PermissionResponse[] = []
const updatingPermissions: PermissionResponse[] = []
const newPermissions: RegisterPermissionPayload[] = []
permissionsToAdd.forEach((permission) => {
const existingPermission = findExistingPermission(permissions, permission)
if (existingPermission) {
existingPermissions.push(existingPermission)
return
}
const updatingPermission = findUpdatingPermission(permissions, permission)
if (updatingPermission) {
updatingPermissions.push(updatingPermission)
return
}
newPermissions.push(permission)
})
for (const permission of newPermissions) {
await axios
.post('/SASjsApi/permission', permission)
.then((res) => {
newAddedPermissions.push(res.data)
})
.catch((error) => {
errorPaths.push(permission.path)
})
}
for (const permission of updatingPermissions) {
await axios
.patch(`/SASjsApi/permission/${permission.permissionId}`, {
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
})
.then((res) => {
updatedPermissions.push(res.data)
})
.catch((error) => {
errorPaths.push(permission.path)
})
}
fetchPermissions()
setIsLoading(false)
setPermissionResponsePayload({
permissionType,
principalType,
principal,
permissionSetting,
existingPermissions,
updatedPermissions,
newAddedPermissions,
errorPaths
})
setOpenPermissionResponseModal(true)
}
const handleUpdatePermissionClick = (permission: PermissionResponse) => {
@@ -278,11 +356,11 @@ const Permission = () => {
) : (
<Box className="permissions-page">
<Grid container direction="column" spacing={1}>
<Grid item xs={12}>
<BootstrapGridItem item xs={12}>
<Paper elevation={3} sx={{ display: 'flex' }}>
<Tooltip title="Filter Permissions">
<IconButton>
<FilterListIcon onClick={() => setFilterModalOpen(true)} />
<IconButton onClick={() => setFilterModalOpen(true)}>
<FilterListIcon />
</IconButton>
</Tooltip>
{appContext.isAdmin && (
@@ -297,14 +375,14 @@ const Permission = () => {
</Tooltip>
)}
</Paper>
</Grid>
<Grid item xs={12}>
</BootstrapGridItem>
<BootstrapGridItem item xs={12}>
<PermissionTable
permissions={filterApplied ? filteredPermissions : permissions}
handleUpdatePermissionClick={handleUpdatePermissionClick}
handleDeletePermissionClick={handleDeletePermissionClick}
/>
</Grid>
</BootstrapGridItem>
</Grid>
<BootstrapSnackbar
open={openSnackbar}
@@ -322,8 +400,8 @@ const Permission = () => {
open={filterModalOpen}
handleOpen={setFilterModalOpen}
permissions={permissions}
uriFilter={uriFilter}
setUriFilter={setUriFilter}
pathFilter={pathFilter}
setPathFilter={setPathFilter}
principalFilter={principalFilter}
setPrincipalFilter={setPrincipalFilter}
principalTypeFilter={principalTypeFilter}
@@ -338,6 +416,11 @@ const Permission = () => {
handleOpen={setAddPermissionModalOpen}
addPermission={addPermission}
/>
<PermissionResponseModal
open={openPermissionResponseModal}
setOpen={setOpenPermissionResponseModal}
payload={permissionResponsePayload}
/>
<UpdatePermissionModal
open={updatePermissionModalOpen}
handleOpen={setUpdatePermissionModalOpen}
@@ -374,9 +457,10 @@ const PermissionTable = ({
<Table sx={{ minWidth: 650 }}>
<TableHead sx={{ background: 'rgb(0,0,0, 0.3)' }}>
<TableRow>
<BootstrapTableCell>Uri</BootstrapTableCell>
<BootstrapTableCell>Path</BootstrapTableCell>
<BootstrapTableCell>Permission Type</BootstrapTableCell>
<BootstrapTableCell>Principal</BootstrapTableCell>
<BootstrapTableCell>Type</BootstrapTableCell>
<BootstrapTableCell>Principal Type</BootstrapTableCell>
<BootstrapTableCell>Setting</BootstrapTableCell>
{appContext.isAdmin && (
<BootstrapTableCell>Action</BootstrapTableCell>
@@ -386,7 +470,8 @@ const PermissionTable = ({
<TableBody>
{permissions.map((permission) => (
<TableRow key={permission.permissionId}>
<BootstrapTableCell>{permission.uri}</BootstrapTableCell>
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
<BootstrapTableCell>
{displayPrincipal(permission)}
</BootstrapTableCell>
@@ -474,8 +559,8 @@ const DisplayGroup = ({ group }: DisplayGroupProps) => {
<Typography sx={{ p: 1 }} variant="h6" component="div">
Group Members
</Typography>
{group.users.map((user) => (
<Typography sx={{ p: 1 }} component="li">
{group.users.map((user, index) => (
<Typography key={index} sx={{ p: 1 }} component="li">
{user.username}
</Typography>
))}

View File

@@ -27,8 +27,8 @@ type FilterModalProps = {
open: boolean
handleOpen: Dispatch<SetStateAction<boolean>>
permissions: PermissionResponse[]
uriFilter: string[]
setUriFilter: Dispatch<SetStateAction<string[]>>
pathFilter: string[]
setPathFilter: Dispatch<SetStateAction<string[]>>
principalFilter: string[]
setPrincipalFilter: Dispatch<SetStateAction<string[]>>
principalTypeFilter: PrincipalType[]
@@ -43,8 +43,8 @@ const PermissionFilterModal = ({
open,
handleOpen,
permissions,
uriFilter,
setUriFilter,
pathFilter,
setPathFilter,
principalFilter,
setPrincipalFilter,
principalTypeFilter,
@@ -54,8 +54,8 @@ const PermissionFilterModal = ({
applyFilter,
resetFilter
}: FilterModalProps) => {
const URIs = permissions
.map((permission) => permission.uri)
const paths = permissions
.map((permission) => permission.path)
.filter((uri, index, array) => array.indexOf(uri) === index)
// fetch all the principals from permissions array
@@ -86,13 +86,13 @@ const PermissionFilterModal = ({
<Grid item xs={12}>
<Autocomplete
multiple
options={URIs}
options={paths}
filterSelectedOptions
value={uriFilter}
value={pathFilter}
onChange={(event: any, newValue: string[]) => {
setUriFilter(newValue)
setPathFilter(newValue)
}}
renderInput={(params) => <TextField {...params} label="URIs" />}
renderInput={(params) => <TextField {...params} label="Paths" />}
/>
</Grid>
<Grid item xs={12}>

View File

@@ -1,4 +1,11 @@
import React, { useEffect, useRef, useState, useContext } from 'react'
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
useContext
} from 'react'
import axios from 'axios'
import {
@@ -58,13 +65,17 @@ const StyledTab = styled(Tab)(() => ({
type SASjsEditorProps = {
selectedFilePath: string
setSelectedFilePath: (filePath: string, refreshSideBar?: boolean) => void
tab: string
setTab: Dispatch<SetStateAction<string>>
}
const baseUrl = window.location.origin
const SASjsEditor = ({
selectedFilePath,
setSelectedFilePath
setSelectedFilePath,
tab,
setTab
}: SASjsEditorProps) => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
@@ -81,7 +92,6 @@ const SASjsEditor = ({
const [log, setLog] = useState('')
const [ctrlPressed, setCtrlPressed] = useState(false)
const [webout, setWebout] = useState('')
const [tab, setTab] = useState('1')
const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState('')
const [selectedFileExtension, setSelectedFileExtension] = useState('')
@@ -161,7 +171,7 @@ const SASjsEditor = ({
}
setLog('')
setWebout('')
setTab('1')
setTab('code')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFilePath])
@@ -200,10 +210,11 @@ const SASjsEditor = ({
setLog(parsedLog)
setWebout(`${res.data?._webout}`)
setTab('2')
setTab('log')
// Scroll to bottom of log
window.scrollTo(0, document.body.scrollHeight)
const logElement = document.getElementById('log')
if (logElement) logElement.scrollTop = logElement.scrollHeight
})
.catch((err) => {
setModalTitle('Abort')
@@ -353,29 +364,24 @@ const SASjsEditor = ({
sx={{
borderBottom: 1,
borderColor: 'divider',
position: 'fixed',
background: 'white',
width: '85%'
background: 'white'
}}
>
<TabList onChange={handleTabChange} centered>
<StyledTab label="Code" value="1" />
<StyledTab label="Log" value="2" />
<StyledTab label="Code" value="code" />
<StyledTab label="Log" value="log" />
<StyledTab
label={
<Tooltip title="Displays content from the _webout fileref">
<Typography>Webout</Typography>
</Tooltip>
}
value="3"
value="webout"
/>
</TabList>
</Box>
<StyledTabPanel
sx={{ paddingBottom: 0, marginTop: '45px' }}
value="1"
>
<StyledTabPanel sx={{ paddingBottom: 0 }} value="code">
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<RunMenu
fileContent={fileContent}
@@ -441,14 +447,16 @@ const SASjsEditor = ({
</p>
</Paper>
</StyledTabPanel>
<StyledTabPanel value="2">
<div style={{ marginTop: '50px' }}>
<h2>SAS Log</h2>
<pre>{log}</pre>
<StyledTabPanel value="log">
<div>
<h2>Log</h2>
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}>
{log}
</pre>
</div>
</StyledTabPanel>
<StyledTabPanel value="3">
<div style={{ marginTop: '50px' }}>
<StyledTabPanel value="webout">
<div>
<pre>{webout}</pre>
</div>
</StyledTabPanel>

View File

@@ -14,6 +14,7 @@ const Studio = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [selectedFilePath, setSelectedFilePath] = useState('')
const [directoryData, setDirectoryData] = useState<TreeNode | null>(null)
const [tab, setTab] = useState('code')
useEffect(() => {
setSelectedFilePath(searchParams.get('filePath') ?? '')
@@ -83,16 +84,20 @@ const Studio = () => {
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<SideBar
selectedFilePath={selectedFilePath}
directoryData={directoryData}
handleSelect={handleSelect}
removeFileFromTree={removeFileFromTree}
refreshSideBar={fetchDirectoryData}
/>
{tab === 'code' && (
<SideBar
selectedFilePath={selectedFilePath}
directoryData={directoryData}
handleSelect={handleSelect}
removeFileFromTree={removeFileFromTree}
refreshSideBar={fetchDirectoryData}
/>
)}
<SASjsEditor
selectedFilePath={selectedFilePath}
setSelectedFilePath={handleSelect}
tab={tab}
setTab={setTab}
/>
</Box>
)

View File

@@ -1,6 +1,15 @@
import React, { useState, useMemo } from 'react'
import axios from 'axios'
import { Backdrop, Box, CircularProgress, Drawer, Toolbar } from '@mui/material'
import {
Backdrop,
Box,
Paper,
CircularProgress,
Drawer,
Toolbar,
IconButton
} from '@mui/material'
import { FolderOpen } from '@mui/icons-material'
import TreeView from '../../components/tree'
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
@@ -33,6 +42,17 @@ const SideBar = ({
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertSeverityType>(
AlertSeverityType.Success
)
const [mobileOpen, setMobileOpen] = React.useState(false)
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
const handleFileSelect = (filePath: string) => {
setMobileOpen(false)
handleSelect(filePath)
}
const defaultExpanded = useMemo(() => {
const splittedPath = selectedFilePath.split('/')
const arr = ['']
@@ -147,15 +167,8 @@ const SideBar = ({
.finally(() => setIsLoading(false))
}
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' }
}}
>
const drawer = (
<div>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isLoading}
@@ -168,7 +181,7 @@ const SideBar = ({
<TreeView
node={directoryData}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
handleSelect={handleFileSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
@@ -189,7 +202,65 @@ const SideBar = ({
title={modalTitle}
payload={modalPayload}
/>
</Drawer>
</div>
)
return (
<>
<Box
component={Paper}
sx={{
margin: '5px',
height: '97vh',
paddingTop: '45px',
display: 'flex',
alignItems: 'flex-start'
}}
>
<IconButton
color="inherit"
size="large"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ left: '5px', display: { md: 'none' } }}
>
<FolderOpen />
</IconButton>
</Box>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', md: 'none' },
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: 240,
boxSizing: 'border-box'
}
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: 'border-box'
}
}}
>
{drawer}
</Drawer>
</>
)
}

View File

@@ -80,7 +80,18 @@ const AppContextProvider = (props: { children: ReactNode }) => {
})
.catch(() => {
setLoggedIn(false)
axios.get('/') // get CSRF TOKEN
// get CSRF TOKEN and set cookie
axios
.get('/')
.then((res) => res.data)
.then((data: string) => {
const result =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
data
)?.[1]
if (result) document.cookie = result
})
})
axios

View File

@@ -13,7 +13,7 @@ code {
}
.main {
margin-top: 50px;
margin: 50px 10px 0 10px;
display: flex;
flex-direction: column;
align-items: center;

59
web/src/utils/helper.ts Normal file
View File

@@ -0,0 +1,59 @@
import { PermissionResponse, RegisterPermissionPayload } from './types'
export const findExistingPermission = (
existingPermissions: PermissionResponse[],
newPermission: RegisterPermissionPayload
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
}
return null
}
export const findUpdatingPermission = (
existingPermissions: PermissionResponse[],
newPermission: RegisterPermissionPayload
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
}
return null
}
const hasSameCombination = (
existingPermission: PermissionResponse,
newPermission: RegisterPermissionPayload
) =>
existingPermission.path === newPermission.path &&
existingPermission.type === newPermission.type &&
existingPermission.setting === newPermission.setting
const hasDifferentSetting = (
existingPermission: PermissionResponse,
newPermission: RegisterPermissionPayload
) =>
existingPermission.path === newPermission.path &&
existingPermission.type === newPermission.type &&
existingPermission.setting !== newPermission.setting

View File

@@ -18,14 +18,16 @@ export interface GroupDetailsResponse extends GroupResponse {
export interface PermissionResponse {
permissionId: number
uri: string
path: string
type: string
setting: string
user?: UserResponse
group?: GroupDetailsResponse
}
export interface RegisterPermissionPayload {
uri: string
path: string
type: string
setting: string
principalType: string
principalId: number