From cfc84ce2f3f31b1138cd0b6077a2ff5487707fc6 Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 3 Sep 2020 19:20:14 +0900 Subject: [PATCH] Permission System (#47) * feat(api): permissions system Adds a permission system for isAuthenticated middleware. Also adds user CRUD. --- .vscode/settings.json | 2 +- server/entity/User.ts | 24 +++++++--- server/lib/permissions.ts | 38 +++++++++++++++ server/middleware/auth.ts | 24 ++++++---- server/overseerr-api.yml | 97 +++++++++++++++++++++++++++++++++++++++ server/routes/auth.ts | 5 +- server/routes/index.ts | 9 +++- server/routes/user.ts | 61 ++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 server/lib/permissions.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e3b6a49d..3947e816 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,7 @@ "previewLimit": 50, "driver": "SQLite", "name": "Local SQLite", - "database": "./db/db.sqlite3" + "database": "./config/db/db.sqlite3" } ] } diff --git a/server/entity/User.ts b/server/entity/User.ts index 911af457..86126f35 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, UpdateDateColumn, } from 'typeorm'; +import { Permission, hasPermission } from '../lib/permissions'; @Entity() export class User { @@ -12,6 +13,8 @@ export class User { return users.map((u) => u.filter()); } + static readonly filteredFields: string[] = ['plexToken']; + @PrimaryGeneratedColumn() public id: number; @@ -21,6 +24,9 @@ export class User { @Column({ nullable: true }) public plexToken?: string; + @Column({ type: 'integer', default: 0 }) + public permissions = 0; + @CreateDateColumn() public createdAt: Date; @@ -32,11 +38,17 @@ export class User { } public filter(): Partial { - return { - id: this.id, - email: this.email, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - }; + const filtered: Partial = Object.assign( + {}, + ...(Object.keys(this) as (keyof User)[]) + .filter((k) => !User.filteredFields.includes(k)) + .map((k) => ({ [k]: this[k] })) + ); + + return filtered; + } + + public hasPermission(permissions: Permission | Permission[]): boolean { + return !!hasPermission(permissions, this.permissions); } } diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts new file mode 100644 index 00000000..fcb9e0e1 --- /dev/null +++ b/server/lib/permissions.ts @@ -0,0 +1,38 @@ +export enum Permission { + NONE = 0, + ADMIN = 2, + MANAGE_SETTINGS = 4, + MANAGE_USERS = 8, + MANAGE_REQUESTS = 16, + REQUEST = 32, + VOTE = 64, +} + +/** + * Takes a Permission and the users permission value and determines + * if the user has access to the permission provided. If the user has + * the admin permission, true will always be returned from this check! + * + * @param permissions Single permission or array of permissions + * @param value users current permission value + */ +export const hasPermission = ( + permissions: Permission | Permission[], + value: number +): boolean => { + let total = 0; + + // If we are not checking any permissions, bail out and return true + if (permissions === 0) { + return true; + } + + if (Array.isArray(permissions)) { + // Combine all permission values into one + total = permissions.reduce((a, v) => a + v, 0); + } else { + total = permissions; + } + + return !!(value & Permission.ADMIN) || !!(value & total); +}; diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index a0a625ea..97814772 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,5 +1,6 @@ import { getRepository } from 'typeorm'; import { User } from '../entity/User'; +import { Permission } from '../lib/permissions'; export const checkUser: Middleware = async (req, _res, next) => { if (req.session?.userId) { @@ -16,13 +17,18 @@ export const checkUser: Middleware = async (req, _res, next) => { next(); }; -export const isAuthenticated: Middleware = async (req, res, next) => { - if (!req.user) { - res.status(403).json({ - status: 403, - error: 'You do not have permisson to access this endpoint', - }); - } else { - next(); - } +export const isAuthenticated = ( + permissions?: Permission | Permission[] +): Middleware => { + const authMiddleware: Middleware = (req, res, next) => { + if (!req.user || !req.user.hasPermission(permissions ?? 0)) { + res.status(403).json({ + status: 403, + error: 'You do not have permisson to access this endpoint', + }); + } else { + next(); + } + }; + return authMiddleware; }; diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index e0d0b6b6..b6290ba0 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -13,20 +13,28 @@ components: id: type: integer example: 1 + readOnly: true email: type: string example: 'hey@itsme.com' plexToken: type: string + readOnly: true + permissions: + type: number + example: 0 createdAt: type: string example: '2020-09-02T05:02:23.000Z' + readOnly: true updatedAt: type: string example: '2020-09-02T05:02:23.000Z' + readOnly: true required: - id - email + - permissions - createdAt - updatedAt MainSettings: @@ -478,5 +486,94 @@ paths: type: array items: $ref: '#/components/schemas/User' + post: + summary: Create a new user + description: | + Creates a new user. Should under normal circumstances never be called as you will not have a valid authToken to provide for the user. + + In the future when Plex auth is not required, this will be used to create accounts. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '201': + description: The created user in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /user/{userId}: + get: + summary: Retrieve a user by ID + description: | + Retrieve user details in JSON format. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: Users details in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update a user by user ID + description: | + Update a user with provided values in request body. You cannot update a users plex token through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + summary: Delete a user by user ID + description: Deletes a user by provided user ID. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/User' + security: - cookieAuth: [] diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ca428bbd..d1454632 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -3,10 +3,11 @@ import { getRepository } from 'typeorm'; import { User } from '../entity/User'; import PlexTvAPI from '../api/plextv'; import { isAuthenticated } from '../middleware/auth'; +import { Permission } from '../lib/permissions'; const authRoutes = Router(); -authRoutes.get('/me', isAuthenticated, async (req, res) => { +authRoutes.get('/me', isAuthenticated(), async (req, res) => { const userRepository = getRepository(User); if (!req.user) { return res.status(500).json({ @@ -54,7 +55,7 @@ authRoutes.post('/login', async (req, res) => { user = new User({ email: account.email, plexToken: account.authToken, - // TODO: When we add permissions in #52, set admin here + permissions: Permission.ADMIN, }); await userRepository.save(user); } diff --git a/server/routes/index.ts b/server/routes/index.ts index 19e5d063..9669bf4a 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -3,12 +3,17 @@ import user from './user'; import authRoutes from './auth'; import { checkUser, isAuthenticated } from '../middleware/auth'; import settingsRoutes from './settings'; +import { Permission } from '../lib/permissions'; const router = Router(); router.use(checkUser); -router.use('/user', isAuthenticated, user); -router.use('/settings', isAuthenticated, settingsRoutes); +router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); +router.use( + '/settings', + isAuthenticated(Permission.MANAGE_SETTINGS), + settingsRoutes +); router.use('/auth', authRoutes); router.get('/', (req, res) => { diff --git a/server/routes/user.ts b/server/routes/user.ts index 1941eaea..58aef5de 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -12,4 +12,65 @@ router.get('/', async (req, res) => { return res.status(200).json(User.filterMany(users)); }); +router.post('/', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = new User({ + email: req.body.email, + permissions: req.body.permissions, + plexToken: '', + }); + await userRepository.save(user); + return res.status(201).json(user.filter()); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +router.get<{ id: string }>('/:id', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + return res.status(200).json(user.filter()); + } catch (e) { + next({ status: 404, message: 'User not found' }); + } +}); + +router.put<{ id: string }>('/:id', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + Object.assign(user, req.body); + await userRepository.save(user); + + return res.status(200).json(user.filter()); + } catch (e) { + next({ status: 404, message: 'User not found' }); + } +}); + +router.delete<{ id: string }>('/:id', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + await userRepository.delete(user.id); + return res.status(200).json(user.filter()); + } catch (e) { + next({ status: 404, message: 'User not found' }); + } +}); + export default router;