diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index 4fe61f4..ec7814c 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -279,10 +279,13 @@ components: type: string displayName: type: string + isAdmin: + type: boolean required: - id - username - displayName + - isAdmin type: object additionalProperties: false GroupResponse: @@ -445,6 +448,16 @@ components: - runTimes type: object additionalProperties: false + AuthorizedRoutesResponse: + properties: + URIs: + items: + type: string + type: array + required: + - URIs + type: object + additionalProperties: false ExecuteReturnJsonPayload: properties: _program: @@ -488,6 +501,71 @@ 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 @@ -913,7 +991,7 @@ paths: type: array examples: 'Example 1': - value: [{id: 123, username: johnusername, displayName: John}, {id: 456, username: starkusername, displayName: Stark}] + value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}] summary: 'Get list of all users (username, displayname). All users can request this.' tags: - User @@ -1342,6 +1420,24 @@ 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 @@ -1354,7 +1450,7 @@ paths: $ref: '#/components/schemas/UserResponse' examples: 'Example 1': - value: {id: 123, username: johnusername, displayName: John} + value: {id: 123, username: johnusername, displayName: John, isAdmin: false} summary: 'Get session info (username).' tags: - Session @@ -1449,7 +1545,7 @@ paths: application/json: schema: properties: - user: {properties: {displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [displayName, username, id], type: object} + 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 @@ -1504,6 +1600,109 @@ 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: / @@ -1517,6 +1716,9 @@ tags: - name: User description: 'Operations about users' + - + name: Permission + description: 'Operations about permissions' - name: Client description: 'Operations about clients' diff --git a/api/scripts/compileSysInit.ts b/api/scripts/compileSysInit.ts index f094324..4c04f64 100644 --- a/api/scripts/compileSysInit.ts +++ b/api/scripts/compileSysInit.ts @@ -6,7 +6,7 @@ import { readFile, SASJsFileType } from '@sasjs/utils' -import { apiRoot, sysInitCompiledPath } from '../src/utils' +import { apiRoot, sysInitCompiledPath } from '../src/utils/file' const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') diff --git a/api/scripts/copySASjsCore.ts b/api/scripts/copySASjsCore.ts index 6dcb02e..614c8ee 100644 --- a/api/scripts/copySASjsCore.ts +++ b/api/scripts/copySASjsCore.ts @@ -8,7 +8,11 @@ import { listFilesInFolder } from '@sasjs/utils' -import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils' +import { + apiRoot, + sasJSCoreMacros, + sasJSCoreMacrosInfo +} from '../src/utils/file' const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') diff --git a/api/src/app.ts b/api/src/app.ts index de27567..79d26c8 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -11,7 +11,6 @@ import cors from 'cors' import helmet from 'helmet' import { - connectDB, copySASjsCore, CorsType, getWebBuildFolder, diff --git a/api/src/controllers/group.ts b/api/src/controllers/group.ts index 9a3bc40..9f6e41b 100644 --- a/api/src/controllers/group.ts +++ b/api/src/controllers/group.ts @@ -198,7 +198,7 @@ const getGroup = async (findBy: GetGroupBy): Promise => { 'groupId name description isActive users -_id' ).populate( 'users', - 'id username displayName -_id' + 'id username displayName isAdmin -_id' )) as unknown as GroupDetailsResponse if (!group) throw { diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index 80bbbd1..ff88ac6 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -4,6 +4,7 @@ export * from './code' export * from './drive' export * from './group' export * from './info' +export * from './permission' export * from './session' export * from './stp' export * from './user' diff --git a/api/src/controllers/info.ts b/api/src/controllers/info.ts index 5c4cf86..08cd1d5 100644 --- a/api/src/controllers/info.ts +++ b/api/src/controllers/info.ts @@ -1,4 +1,8 @@ import { Route, Tags, Example, Get } from 'tsoa' +import { getAuthorizedRoutes } from '../utils' +export interface AuthorizedRoutesResponse { + URIs: string[] +} export interface InfoResponse { mode: string @@ -36,4 +40,19 @@ export class InfoController { } return response } + + /** + * @summary Get authorized routes. + * + */ + @Example({ + URIs: ['/AppStream', '/SASjsApi/stp/execute'] + }) + @Get('/authorizedRoutes') + public authorizedRoutes(): AuthorizedRoutesResponse { + const response = { + URIs: getAuthorizedRoutes() + } + return response + } } diff --git a/api/src/controllers/permission.ts b/api/src/controllers/permission.ts new file mode 100644 index 0000000..14f4c18 --- /dev/null +++ b/api/src/controllers/permission.ts @@ -0,0 +1,331 @@ +import { + Security, + Route, + Tags, + Path, + Example, + Get, + Post, + Patch, + Delete, + Body +} from 'tsoa' + +import Permission from '../model/Permission' +import User from '../model/User' +import Group from '../model/Group' +import { UserResponse } from './user' +import { GroupDetailsResponse } from './group' + +export enum PrincipalType { + user = 'user', + group = 'group' +} + +export enum PermissionSetting { + grant = 'Grant', + deny = 'Deny' +} + +interface RegisterPermissionPayload { + /** + * Name of affected resource + * @example "/SASjsApi/code/execute" + */ + uri: string + /** + * The indication of whether (and to what extent) access is provided + * @example "Grant" + */ + setting: PermissionSetting + /** + * Indicates the type of principal + * @example "user" + */ + principalType: PrincipalType + /** + * The id of user or group to which a rule is assigned. + * @example 123 + */ + principalId: number +} + +interface UpdatePermissionPayload { + /** + * The indication of whether (and to what extent) access is provided + * @example "Grant" + */ + setting: PermissionSetting +} + +export interface PermissionDetailsResponse { + permissionId: number + uri: string + setting: string + user?: UserResponse + group?: GroupDetailsResponse +} + +@Security('bearerAuth') +@Route('SASjsApi/permission') +@Tags('Permission') +export class PermissionController { + /** + * @summary Get list of all permissions (uri, setting and userDetail). + * + */ + @Example([ + { + 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 { + return getAllPermissions() + } + + /** + * @summary Create a new permission. Admin only. + * + */ + @Example({ + 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 { + return createPermission(body) + } + + /** + * @summary Update permission setting. Admin only + * @param permissionId The permission's identifier + * @example permissionId 1234 + */ + @Example({ + 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 { + 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 => + (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 => { + 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 => { + 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 }) +} diff --git a/api/src/controllers/session.ts b/api/src/controllers/session.ts index 0a3562a..0571529 100644 --- a/api/src/controllers/session.ts +++ b/api/src/controllers/session.ts @@ -13,7 +13,8 @@ export class SessionController { @Example({ id: 123, username: 'johnusername', - displayName: 'John' + displayName: 'John', + isAdmin: false }) @Get('/') public async session( @@ -26,5 +27,6 @@ export class SessionController { const session = (req: express.Request) => ({ id: req.user!.userId, username: req.user!.username, - displayName: req.user!.displayName + displayName: req.user!.displayName, + isAdmin: req.user!.isAdmin }) diff --git a/api/src/controllers/user.ts b/api/src/controllers/user.ts index cbb3d81..f410853 100644 --- a/api/src/controllers/user.ts +++ b/api/src/controllers/user.ts @@ -24,9 +24,10 @@ export interface UserResponse { id: number username: string displayName: string + isAdmin: boolean } -interface UserDetailsResponse { +export interface UserDetailsResponse { id: number displayName: string username: string @@ -48,12 +49,14 @@ export class UserController { { id: 123, username: 'johnusername', - displayName: 'John' + displayName: 'John', + isAdmin: false }, { id: 456, username: 'starkusername', - displayName: 'Stark' + displayName: 'Stark', + isAdmin: true } ]) @Get('/') @@ -200,7 +203,7 @@ export class UserController { const getAllUsers = async (): Promise => await User.find({}) - .select({ _id: 0, id: 1, username: 1, displayName: 1 }) + .select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 }) .exec() const createUser = async (data: UserPayload): Promise => { diff --git a/api/src/controllers/web.ts b/api/src/controllers/web.ts index 7cef99d..ada7550 100644 --- a/api/src/controllers/web.ts +++ b/api/src/controllers/web.ts @@ -99,7 +99,8 @@ const login = async ( user: { id: user.id, username: user.username, - displayName: user.displayName + displayName: user.displayName, + isAdmin: user.isAdmin } } } diff --git a/api/src/middlewares/authenticateToken.ts b/api/src/middlewares/authenticateToken.ts index f44c459..24ed1e8 100644 --- a/api/src/middlewares/authenticateToken.ts +++ b/api/src/middlewares/authenticateToken.ts @@ -1,8 +1,14 @@ import { RequestHandler, Request, Response, NextFunction } from 'express' import jwt from 'jsonwebtoken' import { csrfProtection } from '../app' -import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils' +import { + fetchLatestAutoExec, + ModeType, + verifyTokenInDB, + isAuthorizingRoute +} from '../utils' import { desktopUser } from './desktop' +import { authorize } from './authorize' export const authenticateAccessToken: RequestHandler = async ( req, @@ -15,6 +21,10 @@ export const authenticateAccessToken: RequestHandler = async ( return next() } + const nextFunction = isAuthorizingRoute(req) + ? () => authorize(req, res, next) + : next + // if request is coming from web and has valid session // it can be validated. if (req.session?.loggedIn) { @@ -24,7 +34,7 @@ export const authenticateAccessToken: RequestHandler = async ( if (user) { if (user.isActive) { req.user = user - return csrfProtection(req, res, next) + return csrfProtection(req, res, nextFunction) } else return res.sendStatus(401) } } @@ -34,7 +44,7 @@ export const authenticateAccessToken: RequestHandler = async ( authenticateToken( req, res, - next, + nextFunction, process.secrets.ACCESS_TOKEN_SECRET, 'accessToken' ) @@ -58,7 +68,7 @@ const authenticateToken = ( tokenType: 'accessToken' | 'refreshToken' ) => { const { MODE } = process.env - if (MODE?.trim() !== 'server') { + if (MODE === ModeType.Desktop) { req.user = { userId: 1234, clientId: 'desktopModeClientId', diff --git a/api/src/middlewares/authorize.ts b/api/src/middlewares/authorize.ts new file mode 100644 index 0000000..20c85fa --- /dev/null +++ b/api/src/middlewares/authorize.ts @@ -0,0 +1,36 @@ +import { RequestHandler } from 'express' +import User from '../model/User' +import Permission from '../model/Permission' +import { PermissionSetting } from '../controllers/permission' +import { getUri } from '../utils' + +export const authorize: RequestHandler = async (req, res, next) => { + const { user } = req + + if (!user) { + return res.sendStatus(401) + } + + // no need to check for permissions when user is admin + if (user.isAdmin) return next() + + const dbUser = await User.findOne({ id: user.userId }) + if (!dbUser) return res.sendStatus(401) + + const uri = getUri(req) + + // find permission w.r.t user + const permission = await Permission.findOne({ uri, user: dbUser._id }) + + if (permission) { + if (permission.setting === PermissionSetting.grant) return next() + else return res.sendStatus(401) + } + + // find permission w.r.t user's groups + for (const group of dbUser.groups) { + const groupPermission = await Permission.findOne({ uri, group }) + if (groupPermission?.setting === PermissionSetting.grant) return next() + } + return res.sendStatus(401) +} diff --git a/api/src/middlewares/index.ts b/api/src/middlewares/index.ts index 7798de3..8e64643 100644 --- a/api/src/middlewares/index.ts +++ b/api/src/middlewares/index.ts @@ -2,3 +2,4 @@ export * from './authenticateToken' export * from './desktop' export * from './verifyAdmin' export * from './verifyAdminIfNeeded' +export * from './authorize' diff --git a/api/src/middlewares/verifyAdmin.ts b/api/src/middlewares/verifyAdmin.ts index 4ca26f7..ea04ada 100644 --- a/api/src/middlewares/verifyAdmin.ts +++ b/api/src/middlewares/verifyAdmin.ts @@ -1,8 +1,9 @@ import { RequestHandler } from 'express' +import { ModeType } from '../utils' export const verifyAdmin: RequestHandler = (req, res, next) => { const { MODE } = process.env - if (MODE?.trim() !== 'server') return next() + if (MODE === ModeType.Desktop) return next() const { user } = req if (!user?.isAdmin) return res.status(401).send('Admin account required') diff --git a/api/src/model/Permission.ts b/api/src/model/Permission.ts new file mode 100644 index 0000000..8d9454e --- /dev/null +++ b/api/src/model/Permission.ts @@ -0,0 +1,36 @@ +import mongoose, { Schema, model, Document, Model } from 'mongoose' +const AutoIncrement = require('mongoose-sequence')(mongoose) + +interface IPermissionDocument extends Document { + uri: string + setting: string + permissionId: number + user: Schema.Types.ObjectId + group: Schema.Types.ObjectId +} + +interface IPermission extends IPermissionDocument {} + +interface IPermissionModel extends Model {} + +const permissionSchema = new Schema({ + 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 diff --git a/api/src/routes/api/index.ts b/api/src/routes/api/index.ts index 1bad1c8..04e4a19 100644 --- a/api/src/routes/api/index.ts +++ b/api/src/routes/api/index.ts @@ -17,6 +17,7 @@ import groupRouter from './group' import clientRouter from './client' import authRouter from './auth' import sessionRouter from './session' +import permissionRouter from './permission' const router = express.Router() @@ -35,6 +36,12 @@ router.use('/group', desktopRestrict, groupRouter) router.use('/stp', authenticateAccessToken, stpRouter) router.use('/code', authenticateAccessToken, codeRouter) router.use('/user', desktopRestrict, userRouter) +router.use( + '/permission', + desktopRestrict, + authenticateAccessToken, + permissionRouter +) router.use( '/', diff --git a/api/src/routes/api/info.ts b/api/src/routes/api/info.ts index fb71f66..0b5a587 100644 --- a/api/src/routes/api/info.ts +++ b/api/src/routes/api/info.ts @@ -13,4 +13,14 @@ infoRouter.get('/', async (req, res) => { } }) +infoRouter.get('/authorizedRoutes', async (req, res) => { + const controller = new InfoController() + try { + const response = controller.authorizedRoutes() + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } +}) + export default infoRouter diff --git a/api/src/routes/api/permission.ts b/api/src/routes/api/permission.ts new file mode 100644 index 0000000..1cab853 --- /dev/null +++ b/api/src/routes/api/permission.ts @@ -0,0 +1,69 @@ +import express from 'express' +import { PermissionController } from '../../controllers/' +import { verifyAdmin } from '../../middlewares' +import { + registerPermissionValidation, + updatePermissionValidation +} from '../../utils' + +const permissionRouter = express.Router() +const controller = new PermissionController() + +permissionRouter.get('/', async (req, res) => { + try { + const response = await controller.getAllPermissions() + res.send(response) + } catch (err: any) { + const statusCode = err.code + delete err.code + res.status(statusCode).send(err.message) + } +}) + +permissionRouter.post('/', verifyAdmin, async (req, res) => { + const { error, value: body } = registerPermissionValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) + + try { + const response = await controller.createPermission(body) + res.send(response) + } catch (err: any) { + const statusCode = err.code + delete err.code + res.status(statusCode).send(err.message) + } +}) + +permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => { + const { permissionId } = req.params + + const { error, value: body } = updatePermissionValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) + + try { + const response = await controller.updatePermission(permissionId, body) + res.send(response) + } catch (err: any) { + const statusCode = err.code + delete err.code + res.status(statusCode).send(err.message) + } +}) + +permissionRouter.delete( + '/:permissionId', + verifyAdmin, + async (req: any, res) => { + const { permissionId } = req.params + + try { + await controller.deletePermission(permissionId) + res.status(200).send('Permission Deleted!') + } catch (err: any) { + const statusCode = err.code + delete err.code + res.status(statusCode).send(err.message) + } + } +) +export default permissionRouter diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index e9e6e8a..3bf2109 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -29,7 +29,12 @@ jest .mockImplementation(() => path.join(tmpFolder, 'uploads')) import appPromise from '../../../app' -import { UserController } from '../../../controllers/' +import { + UserController, + PermissionController, + PermissionSetting, + PrincipalType +} from '../../../controllers/' import { getTreeExample } from '../../../controllers/internal' import { generateAccessToken, saveTokensInDB } from '../../../utils/' const { getFilesFolder } = fileUtilModules @@ -48,6 +53,7 @@ describe('drive', () => { let con: Mongoose let mongoServer: MongoMemoryServer const controller = new UserController() + const permissionController = new PermissionController() let accessToken: string @@ -58,11 +64,31 @@ describe('drive', () => { con = await mongoose.connect(mongoServer.getUri()) const dbUser = await controller.createUser(user) - accessToken = generateAccessToken({ - clientId, - userId: dbUser.id + accessToken = await generateAndSaveToken(dbUser.id) + await permissionController.createPermission({ + uri: '/SASjsApi/drive/deploy', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) + await permissionController.createPermission({ + uri: '/SASjsApi/drive/deploy/upload', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) + await permissionController.createPermission({ + uri: '/SASjsApi/drive/file', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) + await permissionController.createPermission({ + uri: '/SASjsApi/drive/folder', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant }) - await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken') }) afterAll(async () => { @@ -945,3 +971,12 @@ describe('drive', () => { const getExampleService = (): ServiceMember => ((getTreeExample().members[0] as FolderMember).members[0] as FolderMember) .members[0] as ServiceMember + +const generateAndSaveToken = async (userId: number) => { + const adminAccessToken = generateAccessToken({ + clientId, + userId + }) + await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken') + return adminAccessToken +} diff --git a/api/src/routes/api/spec/permission.spec.ts b/api/src/routes/api/spec/permission.spec.ts new file mode 100644 index 0000000..39b3f71 --- /dev/null +++ b/api/src/routes/api/spec/permission.spec.ts @@ -0,0 +1,571 @@ +import { Express } from 'express' +import mongoose, { Mongoose } from 'mongoose' +import { MongoMemoryServer } from 'mongodb-memory-server' +import request from 'supertest' +import appPromise from '../../../app' +import { + DriveController, + UserController, + GroupController, + ClientController, + PermissionController, + PrincipalType, + PermissionSetting +} from '../../../controllers/' +import { + UserDetailsResponse, + PermissionDetailsResponse +} from '../../../controllers' +import { generateAccessToken, saveTokensInDB } from '../../../utils' + +const deployPayload = { + appLoc: 'string', + streamWebFolder: 'string', + fileTree: { + members: [ + { + name: 'string', + type: 'folder', + members: [ + 'string', + { + name: 'string', + type: 'service', + code: 'string' + } + ] + } + ] + } +} + +const clientId = 'someclientID' +const adminUser = { + displayName: 'Test Admin', + username: 'testAdminUsername', + password: '12345678', + isAdmin: true, + isActive: true +} +const user = { + displayName: 'Test User', + username: 'testUsername', + password: '87654321', + isAdmin: false, + isActive: true +} + +const permission = { + uri: '/SASjsApi/code/execute', + setting: PermissionSetting.grant, + principalType: PrincipalType.user, + principalId: 123 +} + +const group = { + name: 'DCGroup1', + description: 'DC group for testing purposes.' +} + +const userController = new UserController() +const groupController = new GroupController() +const clientController = new ClientController() +const permissionController = new PermissionController() + +describe('permission', () => { + let app: Express + let con: Mongoose + let mongoServer: MongoMemoryServer + let adminAccessToken: string + let dbUser: UserDetailsResponse + + beforeAll(async () => { + app = await appPromise + + mongoServer = await MongoMemoryServer.create() + con = await mongoose.connect(mongoServer.getUri()) + + adminAccessToken = await generateSaveTokenAndCreateUser() + dbUser = await userController.createUser(user) + }) + + afterAll(async () => { + await con.connection.dropDatabase() + await con.connection.close() + await mongoServer.stop() + }) + + describe('create', () => { + afterEach(async () => { + await deleteAllPermissions() + }) + + it('should respond with new permission when principalType is user', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ ...permission, principalId: dbUser.id }) + .expect(200) + + expect(res.body.permissionId).toBeTruthy() + expect(res.body.uri).toEqual(permission.uri) + expect(res.body.setting).toEqual(permission.setting) + expect(res.body.user).toBeTruthy() + }) + + it('should respond with new permission when principalType is group', async () => { + const dbGroup = await groupController.createGroup(group) + + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalType: 'group', + principalId: dbGroup.groupId + }) + .expect(200) + + expect(res.body.permissionId).toBeTruthy() + expect(res.body.uri).toEqual(permission.uri) + expect(res.body.setting).toEqual(permission.setting) + expect(res.body.group).toBeTruthy() + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .send(permission) + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Unauthorized if access token is not of an admin account even if user has permission', async () => { + const accessToken = await generateAndSaveToken(dbUser.id) + + await permissionController.createPermission({ + uri: '/SASjsApi/permission', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) + + const res = await request(app) + .post('/SASjsApi/permission') + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(401) + + expect(res.text).toEqual('Admin account required') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if uri is missing', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + uri: undefined + }) + .expect(400) + + expect(res.text).toEqual(`"uri" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if uri is not valid', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + uri: '/some/random/api/endpoint' + }) + .expect(400) + + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if setting is missing', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + setting: undefined + }) + .expect(400) + + expect(res.text).toEqual(`"setting" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if principalType is missing', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalType: undefined + }) + .expect(400) + + expect(res.text).toEqual(`"principalType" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if principalId is missing', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalId: undefined + }) + .expect(400) + + expect(res.text).toEqual(`"principalId" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if principal type is not valid', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalType: 'invalid' + }) + .expect(400) + + expect(res.text).toEqual('"principalType" must be one of [user, group]') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if setting is not valid', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + setting: 'invalid' + }) + .expect(400) + + expect(res.text).toEqual('"setting" must be one of [Grant, Deny]') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if principalId is not a number', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalId: 'someCharacters' + }) + .expect(400) + + expect(res.text).toEqual('"principalId" must be a number') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if adding permission for admin user', async () => { + const adminUser = await userController.createUser({ + ...user, + username: 'adminUser', + isAdmin: true + }) + + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalId: adminUser.id + }) + .expect(400) + + expect(res.text).toEqual('Can not add permission for admin user.') + expect(res.body).toEqual({}) + }) + + it('should respond with Not Found (404) if user is not found', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalId: 123 + }) + .expect(404) + + expect(res.text).toEqual('User not found.') + expect(res.body).toEqual({}) + }) + + it('should respond with Not Found (404) if group is not found', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + principalType: 'group' + }) + .expect(404) + + expect(res.text).toEqual('Group not found.') + expect(res.body).toEqual({}) + }) + + it('should respond with Conflict (409) if permission already exists', async () => { + await permissionController.createPermission({ + ...permission, + principalId: dbUser.id + }) + + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ ...permission, principalId: dbUser.id }) + .expect(409) + + expect(res.text).toEqual( + 'Permission already exists with provided URI and User.' + ) + expect(res.body).toEqual({}) + }) + }) + + describe('update', () => { + let dbPermission: PermissionDetailsResponse | undefined + beforeAll(async () => { + dbPermission = await permissionController.createPermission({ + ...permission, + principalId: dbUser.id + }) + }) + + afterEach(async () => { + await deleteAllPermissions() + }) + + it('should respond with updated permission', async () => { + const res = await request(app) + .patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send({ setting: 'Deny' }) + .expect(200) + + expect(res.body.setting).toEqual('Deny') + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) + .send(permission) + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Unauthorized if access token is not of an admin account', async () => { + const accessToken = await generateSaveTokenAndCreateUser({ + ...user, + username: 'update' + user.username + }) + + const res = await request(app) + .patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(401) + + expect(res.text).toEqual('Admin account required') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if setting is missing', async () => { + const res = await request(app) + .patch(`/SASjsApi/permission/${dbPermission?.permissionId}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(400) + + expect(res.text).toEqual(`"setting" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if setting is not valid', async () => { + const res = await request(app) + .post('/SASjsApi/permission') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + ...permission, + setting: 'invalid' + }) + .expect(400) + + expect(res.text).toEqual('"setting" must be one of [Grant, Deny]') + expect(res.body).toEqual({}) + }) + + it('should respond with not found (404) if permission with provided id does not exists', async () => { + const res = await request(app) + .patch('/SASjsApi/permission/123') + .auth(adminAccessToken, { type: 'bearer' }) + .send({ + setting: PermissionSetting.deny + }) + .expect(404) + + expect(res.text).toEqual('Permission not found.') + expect(res.body).toEqual({}) + }) + }) + + describe('delete', () => { + it('should delete permission', async () => { + const dbPermission = await permissionController.createPermission({ + ...permission, + principalId: dbUser.id + }) + const res = await request(app) + .delete(`/SASjsApi/permission/${dbPermission?.permissionId}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.text).toEqual('Permission Deleted!') + }) + + it('should respond with not found (404) if permission with provided id does not exists', async () => { + const res = await request(app) + .delete('/SASjsApi/permission/123') + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(404) + + expect(res.text).toEqual('Permission not found.') + }) + }) + + describe('get', () => { + beforeAll(async () => { + await permissionController.createPermission({ + ...permission, + uri: '/test-1', + principalId: dbUser.id + }) + await permissionController.createPermission({ + ...permission, + uri: '/test-2', + principalId: dbUser.id + }) + }) + + it('should give a list of all permissions when user is admin', async () => { + const res = await request(app) + .get('/SASjsApi/permission/') + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body).toHaveLength(2) + }) + + it('should give a list of all permissions when user is not admin', async () => { + const dbUser = await userController.createUser({ + ...user, + username: 'get' + user.username + }) + const accessToken = await generateAndSaveToken(dbUser.id) + await permissionController.createPermission({ + uri: '/SASjsApi/permission', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) + + const res = await request(app) + .get('/SASjsApi/permission/') + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body).toHaveLength(3) + }) + }) + + describe.only('verify', () => { + beforeAll(async () => { + await permissionController.createPermission({ + ...permission, + uri: '/SASjsApi/drive/deploy', + principalId: dbUser.id + }) + }) + + beforeEach(() => { + jest + .spyOn(DriveController.prototype, 'deploy') + .mockImplementation((deployPayload) => + Promise.resolve({ + status: 'success', + message: 'Files deployed successfully to @sasjs/server.' + }) + ) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should create files in SASJS drive', async () => { + const accessToken = await generateAndSaveToken(dbUser.id) + + await request(app) + .get('/SASjsApi/drive/deploy') + .auth(accessToken, { type: 'bearer' }) + .send(deployPayload) + .expect(200) + }) + + it('should respond unauthorized', async () => { + const accessToken = await generateAndSaveToken(dbUser.id) + + await request(app) + .get('/SASjsApi/drive/deploy/upload') + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(401) + }) + }) +}) + +const generateSaveTokenAndCreateUser = async ( + someUser?: any +): Promise => { + 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({}) +} diff --git a/api/src/routes/api/spec/stp.spec.ts b/api/src/routes/api/spec/stp.spec.ts index f80ab8b..eb34570 100644 --- a/api/src/routes/api/spec/stp.spec.ts +++ b/api/src/routes/api/spec/stp.spec.ts @@ -4,7 +4,12 @@ import mongoose, { Mongoose } from 'mongoose' import { MongoMemoryServer } from 'mongodb-memory-server' import request from 'supertest' import appPromise from '../../../app' -import { UserController } from '../../../controllers/' +import { + UserController, + PermissionController, + PermissionSetting, + PrincipalType +} from '../../../controllers/' import { generateAccessToken, saveTokensInDB, @@ -41,12 +46,21 @@ 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()) - accessToken = await generateSaveTokenAndCreateUser(user) + const dbUser = await userController.createUser(user) + accessToken = await generateAndSaveToken(dbUser.id) + await permissionController.createPermission({ + uri: '/SASjsApi/stp/execute', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) }) afterAll(async () => { diff --git a/api/src/routes/api/spec/user.spec.ts b/api/src/routes/api/spec/user.spec.ts index 1c4fd99..12e68d5 100644 --- a/api/src/routes/api/spec/user.spec.ts +++ b/api/src/routes/api/spec/user.spec.ts @@ -770,12 +770,14 @@ describe('user', () => { { id: expect.anything(), username: adminUser.username, - displayName: adminUser.displayName + displayName: adminUser.displayName, + isAdmin: adminUser.isAdmin }, { id: expect.anything(), username: user.username, - displayName: user.displayName + displayName: user.displayName, + isAdmin: user.isAdmin } ]) }) @@ -796,12 +798,14 @@ describe('user', () => { { id: expect.anything(), username: adminUser.username, - displayName: adminUser.displayName + displayName: adminUser.displayName, + isAdmin: adminUser.isAdmin }, { id: expect.anything(), username: 'randomUser', - displayName: user.displayName + displayName: user.displayName, + isAdmin: user.isAdmin } ]) }) diff --git a/api/src/routes/api/spec/web.spec.ts b/api/src/routes/api/spec/web.spec.ts index 4f8ec30..45b07f1 100644 --- a/api/src/routes/api/spec/web.spec.ts +++ b/api/src/routes/api/spec/web.spec.ts @@ -79,7 +79,8 @@ describe('web', () => { expect(res.body.user).toEqual({ id: expect.any(Number), username: user.username, - displayName: user.displayName + displayName: user.displayName, + isAdmin: user.isAdmin }) }) }) diff --git a/api/src/routes/appStream/index.ts b/api/src/routes/appStream/index.ts index 3954039..ca2e279 100644 --- a/api/src/routes/appStream/index.ts +++ b/api/src/routes/appStream/index.ts @@ -1,5 +1,6 @@ import path from 'path' import express, { Request } from 'express' +import { authenticateAccessToken } from '../../middlewares' import { folderExists } from '@sasjs/utils' import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils' @@ -9,7 +10,7 @@ const appStreams: { [key: string]: string } = {} const router = express.Router() -router.get('/', async (req, res) => { +router.get('/', authenticateAccessToken, async (req, res) => { const content = appStreamHtml(process.appStreamConfig) res.cookie('XSRF-TOKEN', req.csrfToken()) @@ -66,7 +67,7 @@ export const publishAppStream = async ( return {} } -router.get(`/*`, function (req: Request, res, next) { +router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) { const reqPath = req.path.replace(/^\//, '') // Redirecting to url with trailing slash for appStream base URL only diff --git a/api/src/utils/appStreamConfig.ts b/api/src/utils/appStreamConfig.ts index c293b35..f4f137d 100644 --- a/api/src/utils/appStreamConfig.ts +++ b/api/src/utils/appStreamConfig.ts @@ -5,6 +5,8 @@ import { AppStreamConfig } from '../types' import { getAppStreamConfigPath } from './file' export const loadAppStreamConfig = async () => { + process.appStreamConfig = {} + if (process.env.NODE_ENV === 'test') return const appStreamConfigPath = getAppStreamConfigPath() @@ -21,7 +23,6 @@ export const loadAppStreamConfig = async () => { } catch (_) { appStreamConfig = {} } - process.appStreamConfig = {} for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) { const { appLoc, streamWebFolder, streamLogo } = entry diff --git a/api/src/utils/getAuthorizedRoutes.ts b/api/src/utils/getAuthorizedRoutes.ts new file mode 100644 index 0000000..93412fa --- /dev/null +++ b/api/src/utils/getAuthorizedRoutes.ts @@ -0,0 +1,35 @@ +import { Request } from 'express' + +const StaticAuthorizedRoutes = [ + '/AppStream', + '/SASjsApi/code/execute', + '/SASjsApi/stp/execute', + '/SASjsApi/drive/deploy', + '/SASjsApi/drive/deploy/upload', + '/SASjsApi/drive/file', + '/SASjsApi/drive/folder', + '/SASjsApi/drive/fileTree', + '/SASjsApi/permission' +] + +export const getAuthorizedRoutes = () => { + const streamingApps = Object.keys(process.appStreamConfig) + const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`) + return [...StaticAuthorizedRoutes, ...streamingAppsRoutes] +} + +export const getUri = (req: Request) => { + const { baseUrl, path: reqPath } = req + + if (baseUrl === '/AppStream') { + const appStream = reqPath.split('/')[1] + + // removing trailing slash of URLs + return (baseUrl + '/' + appStream).replace(/\/$/, '') + } + + return (baseUrl + reqPath).replace(/\/$/, '') +} + +export const isAuthorizingRoute = (req: Request): boolean => + getAuthorizedRoutes().includes(getUri(req)) diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index 36e1efc..13bc904 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './file' export * from './generateAccessToken' export * from './generateAuthCode' export * from './generateRefreshToken' +export * from './getAuthorizedRoutes' export * from './getCertificates' export * from './getDesktopFields' export * from './getPreProgramVariables' diff --git a/api/src/utils/specs/extractHeaders.spec.ts b/api/src/utils/specs/extractHeaders.spec.ts index 9d29f04..780b326 100644 --- a/api/src/utils/specs/extractHeaders.spec.ts +++ b/api/src/utils/specs/extractHeaders.spec.ts @@ -1,4 +1,4 @@ -import { extractHeaders } from '..' +import { extractHeaders } from '../extractHeaders' describe('extractHeaders', () => { it('should return valid http headers', () => { diff --git a/api/src/utils/specs/parseLogToArray.spec.ts b/api/src/utils/specs/parseLogToArray.spec.ts index 3bd0de4..821f041 100644 --- a/api/src/utils/specs/parseLogToArray.spec.ts +++ b/api/src/utils/specs/parseLogToArray.spec.ts @@ -1,4 +1,4 @@ -import { parseLogToArray } from '..' +import { parseLogToArray } from '../parseLogToArray' describe('parseLogToArray', () => { it('should parse log to array type', () => { diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index d8eff6f..0789fa5 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -1,5 +1,6 @@ import Joi from 'joi' -import { RunTimeType } from '.' +import { PermissionSetting, PrincipalType } from '../controllers/permission' +import { getAuthorizedRoutes } from './getAuthorizedRoutes' const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16) const passwordSchema = Joi.string().min(6).max(1024) @@ -86,6 +87,27 @@ export const registerClientValidation = (data: any): Joi.ValidationResult => clientSecret: Joi.string().required() }).validate(data) +export const registerPermissionValidation = (data: any): Joi.ValidationResult => + Joi.object({ + uri: Joi.string() + .required() + .valid(...getAuthorizedRoutes()), + setting: Joi.string() + .required() + .valid(...Object.values(PermissionSetting)), + principalType: Joi.string() + .required() + .valid(...Object.values(PrincipalType)), + principalId: Joi.number().required() + }).validate(data) + +export const updatePermissionValidation = (data: any): Joi.ValidationResult => + Joi.object({ + setting: Joi.string() + .required() + .valid(...Object.values(PermissionSetting)) + }).validate(data) + export const deployValidation = (data: any): Joi.ValidationResult => Joi.object({ appLoc: Joi.string().pattern(/^\//).required().min(2), diff --git a/api/tsoa.json b/api/tsoa.json index f353150..a2ceae0 100644 --- a/api/tsoa.json +++ b/api/tsoa.json @@ -23,6 +23,10 @@ "name": "User", "description": "Operations about users" }, + { + "name": "Permission", + "description": "Operations about permissions" + }, { "name": "Client", "description": "Operations about clients" diff --git a/web/package.json b/web/package.json index a3a5898..8f6681d 100644 --- a/web/package.json +++ b/web/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "start": "npx webpack-dev-server --config webpack.dev.ts --hot", - "build": "npx webpack --config webpack.prod.ts" + "start": "webpack-dev-server --config webpack.dev.ts --hot", + "build": "webpack --config webpack.prod.ts" }, "dependencies": { "@emotion/react": "^11.4.1", diff --git a/web/src/components/dialogTitle.tsx b/web/src/components/dialogTitle.tsx new file mode 100644 index 0000000..5084809 --- /dev/null +++ b/web/src/components/dialogTitle.tsx @@ -0,0 +1,35 @@ +import React, { Dispatch, SetStateAction } from 'react' + +import DialogTitle from '@mui/material/DialogTitle' +import IconButton from '@mui/material/IconButton' +import CloseIcon from '@mui/icons-material/Close' + +export interface DialogTitleProps { + id: string + children?: React.ReactNode + handleOpen: Dispatch> +} + +export const BootstrapDialogTitle = (props: DialogTitleProps) => { + const { children, handleOpen, ...other } = props + + return ( + + {children} + {handleOpen ? ( + handleOpen(false)} + sx={{ + position: 'absolute', + right: 8, + top: 8, + color: (theme) => theme.palette.grey[500] + }} + > + + + ) : null} + + ) +} diff --git a/web/src/components/login.tsx b/web/src/components/login.tsx index a60baa7..c679a8d 100644 --- a/web/src/components/login.tsx +++ b/web/src/components/login.tsx @@ -22,7 +22,7 @@ const Login = () => { username, password }).catch((err: any) => { - setErrorMessage(err.response.data) + setErrorMessage(err.response?.data || err.toString()) return {} }) @@ -30,6 +30,7 @@ const Login = () => { appContext.setUserId?.(user.id) appContext.setUsername?.(user.username) appContext.setDisplayName?.(user.displayName) + appContext.setIsAdmin?.(user.isAdmin) appContext.setLoggedIn?.(loggedIn) } } diff --git a/web/src/components/modal.tsx b/web/src/components/modal.tsx new file mode 100644 index 0000000..223bd9a --- /dev/null +++ b/web/src/components/modal.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import { Typography, Dialog, DialogContent } from '@mui/material' +import { styled } from '@mui/material/styles' + +import { BootstrapDialogTitle } from './dialogTitle' + +const BootstrapDialog = styled(Dialog)(({ theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2) + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1) + } +})) + +export interface ModalProps { + open: boolean + setOpen: React.Dispatch> + title: string + payload: string +} + +const Modal = (props: ModalProps) => { + const { open, setOpen, title, payload } = props + + return ( +
+ setOpen(false)} open={open}> + + {title} + + + + {payload} + + + +
+ ) +} + +export default Modal diff --git a/web/src/components/snackbar.tsx b/web/src/components/snackbar.tsx new file mode 100644 index 0000000..ed4328c --- /dev/null +++ b/web/src/components/snackbar.tsx @@ -0,0 +1,62 @@ +import React, { Dispatch, SetStateAction } from 'react' +import Snackbar from '@mui/material/Snackbar' +import MuiAlert, { AlertProps } from '@mui/material/Alert' +import Slide, { SlideProps } from '@mui/material/Slide' + +const Alert = React.forwardRef(function Alert( + props, + ref +) { + return +}) + +const Transition = (props: SlideProps) => { + return +} + +export enum AlertSeverityType { + Success = 'success', + Warning = 'warning', + Info = 'info', + Error = 'error' +} + +type BootstrapSnackbarProps = { + open: boolean + setOpen: Dispatch> + 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 ( + + + {message} + + + ) +} + +export default BootstrapSnackbar diff --git a/web/src/containers/Settings/addPermissionModal.tsx b/web/src/containers/Settings/addPermissionModal.tsx new file mode 100644 index 0000000..2a147dc --- /dev/null +++ b/web/src/containers/Settings/addPermissionModal.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect, Dispatch, SetStateAction } from 'react' +import axios from 'axios' +import { + Button, + Grid, + Dialog, + DialogContent, + DialogActions, + TextField, + CircularProgress, + Autocomplete +} from '@mui/material' +import { styled } from '@mui/material/styles' + +import { BootstrapDialogTitle } from '../../components/dialogTitle' + +import { + UserResponse, + GroupResponse, + RegisterPermissionPayload +} from '../../utils/types' + +const BootstrapDialog = styled(Dialog)(({ theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2) + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1) + } +})) + +type AddPermissionModalProps = { + open: boolean + handleOpen: Dispatch> + addPermission: (addPermissionPayload: RegisterPermissionPayload) => void +} + +const AddPermissionModal = ({ + open, + handleOpen, + addPermission +}: AddPermissionModalProps) => { + const [URIs, setURIs] = useState([]) + const [loadingURIs, setLoadingURIs] = useState(false) + const [uri, setUri] = useState() + const [principalType, setPrincipalType] = useState('user') + const [userPrincipal, setUserPrincipal] = useState() + const [groupPrincipal, setGroupPrincipal] = useState() + const [permissionSetting, setPermissionSetting] = useState('Grant') + const [loadingPrincipals, setLoadingPrincipals] = useState(false) + const [userPrincipals, setUserPrincipals] = useState([]) + const [groupPrincipals, setGroupPrincipals] = useState([]) + + 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 ( + handleOpen(false)} open={open}> + + Add Permission + + + + + setUri(newValue)} + renderInput={(params) => + loadingURIs ? ( + + ) : ( + + ) + } + /> + + + + setPrincipalType(newValue) + } + renderInput={(params) => ( + + )} + /> + + + {principalType === 'user' ? ( + option.displayName} + disableClearable + value={userPrincipal} + onChange={(event: any, newValue: UserResponse) => + setUserPrincipal(newValue) + } + renderInput={(params) => + loadingPrincipals ? ( + + ) : ( + + ) + } + /> + ) : ( + option.name} + disableClearable + value={groupPrincipal} + onChange={(event: any, newValue: GroupResponse) => + setGroupPrincipal(newValue) + } + renderInput={(params) => + loadingPrincipals ? ( + + ) : ( + + ) + } + /> + )} + + + + setPermissionSetting(newValue) + } + renderInput={(params) => ( + + )} + /> + + + + + + + + ) +} + +export default AddPermissionModal diff --git a/web/src/containers/Settings/deletePermissionModal.tsx b/web/src/containers/Settings/deletePermissionModal.tsx new file mode 100644 index 0000000..23736f8 --- /dev/null +++ b/web/src/containers/Settings/deletePermissionModal.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +import { + Button, + Dialog, + DialogContent, + DialogActions, + Typography +} from '@mui/material' +import { styled } from '@mui/material/styles' + +const BootstrapDialog = styled(Dialog)(({ theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2) + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1) + } +})) + +type DeleteModalProps = { + open: boolean + setOpen: React.Dispatch> + deletePermission: () => void +} + +const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => { + return ( + setOpen(false)} open={open}> + + + Are you sure you want to delete this permission? + + + + + + + ) +} + +export default DeleteModal diff --git a/web/src/containers/Settings/index.tsx b/web/src/containers/Settings/index.tsx index 7a3f829..71a8960 100644 --- a/web/src/containers/Settings/index.tsx +++ b/web/src/containers/Settings/index.tsx @@ -1,12 +1,15 @@ -import * as React from 'react' +import React, { useState, useContext } from 'react' import { Box, Paper, Tab, styled } from '@mui/material' import TabContext from '@mui/lab/TabContext' import TabList from '@mui/lab/TabList' import TabPanel from '@mui/lab/TabPanel' +import Permission from './permission' import Profile from './profile' +import { AppContext, ModeType } from '../../context/appContext' + const StyledTab = styled(Tab)({ background: 'black', margin: '0 5px 5px 0' @@ -17,7 +20,8 @@ const StyledTabpanel = styled(TabPanel)({ }) const Settings = () => { - const [value, setValue] = React.useState('profile') + const appContext = useContext(AppContext) + const [value, setValue] = useState('profile') const handleChange = (event: React.SyntheticEvent, newValue: string) => { setValue(newValue) @@ -42,11 +46,17 @@ const Settings = () => { onChange={handleChange} > + {appContext.mode === ModeType.Server && ( + + )} + + + ) diff --git a/web/src/containers/Settings/permission.tsx b/web/src/containers/Settings/permission.tsx new file mode 100644 index 0000000..81b2180 --- /dev/null +++ b/web/src/containers/Settings/permission.tsx @@ -0,0 +1,483 @@ +import React, { useState, useEffect, useContext, useCallback } from 'react' +import axios from 'axios' +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Grid, + CircularProgress, + IconButton, + Tooltip, + Typography, + Popover +} from '@mui/material' + +import FilterListIcon from '@mui/icons-material/FilterList' +import AddIcon from '@mui/icons-material/Add' +import EditIcon from '@mui/icons-material/Edit' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' + +import { styled } from '@mui/material/styles' + +import Modal from '../../components/modal' +import PermissionFilterModal from './permissionFilterModal' +import AddPermissionModal from './addPermissionModal' +import UpdatePermissionModal from './updatePermissionModal' +import DeleteModal from './deletePermissionModal' +import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' + +import { + GroupDetailsResponse, + PermissionResponse, + RegisterPermissionPayload +} from '../../utils/types' +import { AppContext } from '../../context/appContext' + +const BootstrapTableCell = styled(TableCell)({ + textAlign: 'left' +}) + +export enum PrincipalType { + User = 'User', + Group = 'Group' +} + +const Permission = () => { + const appContext = useContext(AppContext) + const [isLoading, setIsLoading] = useState(false) + const [openModal, setOpenModal] = useState(false) + const [modalTitle, setModalTitle] = useState('') + const [modalPayload, setModalPayload] = useState('') + const [openSnackbar, setOpenSnackbar] = useState(false) + const [snackbarMessage, setSnackbarMessage] = useState('') + const [snackbarSeverity, setSnackbarSeverity] = useState( + AlertSeverityType.Success + ) + const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false) + const [updatePermissionModalOpen, setUpdatePermissionModalOpen] = + useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [selectedPermission, setSelectedPermission] = + useState() + const [filterModalOpen, setFilterModalOpen] = useState(false) + const [uriFilter, setUriFilter] = useState([]) + const [principalFilter, setPrincipalFilter] = useState([]) + const [principalTypeFilter, setPrincipalTypeFilter] = useState< + PrincipalType[] + >([]) + const [settingFilter, setSettingFilter] = useState([]) + const [permissions, setPermissions] = useState([]) + 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 ? ( + + ) : ( + + + + + + + setFilterModalOpen(true)} /> + + + {appContext.isAdmin && ( + + setAddPermissionModalOpen(true)}> + + + + )} + + + + + + + + + + + + + + ) +} + +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 ( + + + + + Uri + Principal + Type + Setting + {appContext.isAdmin && ( + Action + )} + + + + {permissions.map((permission) => ( + + {permission.uri} + + {displayPrincipal(permission)} + + + {displayPrincipalType(permission)} + + {permission.setting} + {appContext.isAdmin && ( + + + handleUpdatePermissionClick(permission)} + > + + + + + handleDeletePermissionClick(permission)} + > + + + + + )} + + ))} + +
+
+ ) +} + +const displayPrincipal = (permission: PermissionResponse) => { + if (permission.user) return permission.user.username + if (permission.group) return +} + +type DisplayGroupProps = { + group: GroupDetailsResponse +} + +const DisplayGroup = ({ group }: DisplayGroupProps) => { + const [anchorEl, setAnchorEl] = useState(null) + + const handlePopoverOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handlePopoverClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + + return ( +
+ + {group.name} + + + + Group Members + + {group.users.map((user) => ( + + {user.username} + + ))} + +
+ ) +} + +const displayPrincipalType = (permission: PermissionResponse) => { + if (permission.user) return PrincipalType.User + if (permission.group) return PrincipalType.Group +} diff --git a/web/src/containers/Settings/permissionFilterModal.tsx b/web/src/containers/Settings/permissionFilterModal.tsx new file mode 100644 index 0000000..bbe9177 --- /dev/null +++ b/web/src/containers/Settings/permissionFilterModal.tsx @@ -0,0 +1,154 @@ +import React, { Dispatch, SetStateAction } from 'react' +import { + Button, + Grid, + Dialog, + DialogContent, + DialogActions, + TextField +} from '@mui/material' +import { styled } from '@mui/material/styles' +import Autocomplete from '@mui/material/Autocomplete' + +import { PermissionResponse } from '../../utils/types' +import { BootstrapDialogTitle } from '../../components/dialogTitle' +import { PrincipalType } from './permission' + +const BootstrapDialog = styled(Dialog)(({ theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2) + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1) + } +})) + +type FilterModalProps = { + open: boolean + handleOpen: Dispatch> + permissions: PermissionResponse[] + uriFilter: string[] + setUriFilter: Dispatch> + principalFilter: string[] + setPrincipalFilter: Dispatch> + principalTypeFilter: PrincipalType[] + setPrincipalTypeFilter: Dispatch> + settingFilter: string[] + setSettingFilter: Dispatch> + 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 ( + handleOpen(false)} open={open}> + + Permission Filter + + + + + { + setUriFilter(newValue) + }} + renderInput={(params) => } + /> + + + { + setPrincipalFilter(newValue) + }} + renderInput={(params) => ( + + )} + /> + + + { + setPrincipalTypeFilter(newValue) + }} + renderInput={(params) => ( + + )} + /> + + + { + setSettingFilter(newValue) + }} + renderInput={(params) => ( + + )} + /> + + + + + + + + + ) +} + +export default PermissionFilterModal diff --git a/web/src/containers/Settings/updatePermissionModal.tsx b/web/src/containers/Settings/updatePermissionModal.tsx new file mode 100644 index 0000000..55d92de --- /dev/null +++ b/web/src/containers/Settings/updatePermissionModal.tsx @@ -0,0 +1,84 @@ +import React, { useState, Dispatch, SetStateAction, useEffect } from 'react' +import { + Button, + Grid, + Dialog, + DialogContent, + DialogActions, + TextField +} from '@mui/material' +import { styled } from '@mui/material/styles' +import Autocomplete from '@mui/material/Autocomplete' + +import { BootstrapDialogTitle } from '../../components/dialogTitle' + +import { PermissionResponse } from '../../utils/types' + +const BootstrapDialog = styled(Dialog)(({ theme }) => ({ + '& .MuiDialogContent-root': { + padding: theme.spacing(2) + }, + '& .MuiDialogActions-root': { + padding: theme.spacing(1) + } +})) + +type UpdatePermissionModalProps = { + open: boolean + handleOpen: Dispatch> + 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 ( + handleOpen(false)} open={open}> + + Update Permission + + + + + + setPermissionSetting(newValue) + } + renderInput={(params) => ( + + )} + /> + + + + + + + + ) +} + +export default UpdatePermissionModal diff --git a/web/src/context/appContext.tsx b/web/src/context/appContext.tsx index a91d8e3..4802a57 100644 --- a/web/src/context/appContext.tsx +++ b/web/src/context/appContext.tsx @@ -29,6 +29,8 @@ interface AppContextProps { setUsername: Dispatch> | null displayName: string setDisplayName: Dispatch> | null + isAdmin: boolean + setIsAdmin: Dispatch> | null mode: ModeType runTimes: RunTimeType[] logout: (() => void) | null @@ -44,6 +46,8 @@ export const AppContext = createContext({ setUsername: null, displayName: '', setDisplayName: null, + isAdmin: false, + setIsAdmin: null, mode: ModeType.Server, runTimes: [], logout: null @@ -56,6 +60,7 @@ 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([]) @@ -70,6 +75,7 @@ const AppContextProvider = (props: { children: ReactNode }) => { setUserId(data.id) setUsername(data.username) setDisplayName(data.displayName) + setIsAdmin(data.isAdmin) setLoggedIn(true) }) .catch(() => { @@ -107,6 +113,8 @@ const AppContextProvider = (props: { children: ReactNode }) => { setUsername, displayName, setDisplayName, + isAdmin, + setIsAdmin, mode, runTimes, logout diff --git a/web/src/index.css b/web/src/index.css index 6f5fcc7..34d605c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -18,3 +18,10 @@ code { flex-direction: column; align-items: center; } + +.permissions-page { + display: flex; + flex-direction: column; + padding: '5px 10px'; + margin-top: '10px'; +} diff --git a/web/src/utils/types.ts b/web/src/utils/types.ts new file mode 100644 index 0000000..4f0a80a --- /dev/null +++ b/web/src/utils/types.ts @@ -0,0 +1,32 @@ +export interface UserResponse { + id: number + username: string + displayName: string + isAdmin: boolean +} + +export interface GroupResponse { + groupId: number + name: string + description: string +} + +export interface GroupDetailsResponse extends GroupResponse { + isActive: boolean + users: UserResponse[] +} + +export interface PermissionResponse { + permissionId: number + uri: string + setting: string + user?: UserResponse + group?: GroupDetailsResponse +} + +export interface RegisterPermissionPayload { + uri: string + setting: string + principalType: string + principalId: number +}