diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index cd32743f..2f232af2 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -7,7 +7,7 @@ import pick from "lodash.pick"; import https from "https"; import { ApiUserInfo } from "../data/ApiUserInfo"; import { ApiUserInfoData } from "../data/entities/ApiUserInfo"; -import { ApiPermissions } from "../data/ApiPermissions"; +import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; import { ok } from "./responses"; interface IPassportApiUser { @@ -71,7 +71,7 @@ export function initAuth(app: express.Express) { const apiLogins = new ApiLogins(); const apiUserInfo = new ApiUserInfo(); - const apiPermissions = new ApiPermissions(); + const apiPermissionAssignments = new ApiPermissionAssignments(); // Initialize API tokens passport.use( @@ -105,7 +105,7 @@ export function initAuth(app: express.Express) { const user = await simpleDiscordAPIRequest(accessToken, "users/@me"); // Make sure the user is able to access at least 1 guild - const permissions = await apiPermissions.getByUserId(user.id); + const permissions = await apiPermissionAssignments.getByUserId(user.id); if (permissions.length === 0) { cb(null, {}); return; diff --git a/backend/src/api/guilds.ts b/backend/src/api/guilds.ts index 9087c558..de5d9597 100644 --- a/backend/src/api/guilds.ts +++ b/backend/src/api/guilds.ts @@ -1,35 +1,38 @@ -import express from "express"; -import passport from "passport"; +import express, { Request, Response } from "express"; import { AllowedGuilds } from "../data/AllowedGuilds"; -import { ApiPermissions } from "../data/ApiPermissions"; -import { clientError, error, ok, serverError, unauthorized } from "./responses"; +import { clientError, ok, serverError, unauthorized } from "./responses"; import { Configs } from "../data/Configs"; -import { ApiRoles } from "../data/ApiRoles"; import { validateGuildConfig } from "../configValidator"; import yaml, { YAMLException } from "js-yaml"; import { apiTokenAuthHandlers } from "./auth"; +import { ApiPermissions, hasPermission, permissionArrToSet } from "@shared/apiPermissions"; +import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; export function initGuildsAPI(app: express.Express) { const allowedGuilds = new AllowedGuilds(); - const apiPermissions = new ApiPermissions(); + const apiPermissionAssignments = new ApiPermissionAssignments(); const configs = new Configs(); - app.get("/guilds/available", ...apiTokenAuthHandlers(), async (req, res) => { + app.get("/guilds/available", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { const guilds = await allowedGuilds.getForApiUser(req.user.userId); res.json(guilds); }); - app.get("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => { - const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!permissions) return unauthorized(res); + app.get("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { + const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, req.user.userId); + if (!permAssignment || !hasPermission(permissionArrToSet(permAssignment.permissions), ApiPermissions.ReadConfig)) { + return unauthorized(res); + } const config = await configs.getActiveByKey(`guild-${req.params.guildId}`); res.json({ config: config ? config.config : "" }); }); app.post("/guilds/:guildId/config", ...apiTokenAuthHandlers(), async (req, res) => { - const permissions = await apiPermissions.getByGuildAndUserId(req.params.guildId, req.user.userId); - if (!permissions || ApiRoles[permissions.role] < ApiRoles.Editor) return unauthorized(res); + const permAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, req.user.userId); + if (!permAssignment || !hasPermission(permissionArrToSet(permAssignment.permissions), ApiPermissions.EditConfig)) { + return unauthorized(res); + } let config = req.body.config; if (config == null) return clientError(res, "No config supplied"); diff --git a/backend/src/data/AllowedGuilds.ts b/backend/src/data/AllowedGuilds.ts index a96cf814..3b5a89fb 100644 --- a/backend/src/data/AllowedGuilds.ts +++ b/backend/src/data/AllowedGuilds.ts @@ -9,6 +9,7 @@ import { } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { BaseRepository } from "./BaseRepository"; +import { ApiPermissionTypes } from "./ApiPermissionAssignments"; export class AllowedGuilds extends BaseRepository { private allowedGuilds: Repository; @@ -33,8 +34,8 @@ export class AllowedGuilds extends BaseRepository { .innerJoin( "api_permissions", "api_permissions", - "api_permissions.guild_id = allowed_guilds.id AND api_permissions.user_id = :userId", - { userId }, + "api_permissions.guild_id = allowed_guilds.id AND api_permissions.type = :type AND api_permissions.target_id = :userId", + { type: ApiPermissionTypes.User, userId }, ) .getMany(); } diff --git a/backend/src/data/ApiPermissionAssignments.ts b/backend/src/data/ApiPermissionAssignments.ts new file mode 100644 index 00000000..05e5d1ba --- /dev/null +++ b/backend/src/data/ApiPermissionAssignments.ts @@ -0,0 +1,36 @@ +import { getRepository, Repository } from "typeorm"; +import { ApiPermissionAssignment } from "./entities/ApiPermissionAssignment"; +import { BaseRepository } from "./BaseRepository"; + +export enum ApiPermissionTypes { + User = "USER", + Role = "ROLE", +} + +export class ApiPermissionAssignments extends BaseRepository { + private apiPermissions: Repository; + + constructor() { + super(); + this.apiPermissions = getRepository(ApiPermissionAssignment); + } + + getByUserId(userId) { + return this.apiPermissions.find({ + where: { + type: ApiPermissionTypes.User, + target_id: userId, + }, + }); + } + + getByGuildAndUserId(guildId, userId) { + return this.apiPermissions.findOne({ + where: { + guild_id: guildId, + type: ApiPermissionTypes.User, + target_id: userId, + }, + }); + } +} diff --git a/backend/src/data/ApiPermissions.ts b/backend/src/data/ApiPermissions.ts deleted file mode 100644 index 7fe05d9a..00000000 --- a/backend/src/data/ApiPermissions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getRepository, Repository } from "typeorm"; -import { ApiPermission } from "./entities/ApiPermission"; -import { BaseRepository } from "./BaseRepository"; - -export class ApiPermissions extends BaseRepository { - private apiPermissions: Repository; - - constructor() { - super(); - this.apiPermissions = getRepository(ApiPermission); - } - - getByUserId(userId) { - return this.apiPermissions.find({ - where: { - user_id: userId, - }, - }); - } - - getByGuildAndUserId(guildId, userId) { - return this.apiPermissions.findOne({ - where: { - guild_id: guildId, - user_id: userId, - }, - }); - } -} diff --git a/backend/src/data/ApiRoles.ts b/backend/src/data/ApiRoles.ts deleted file mode 100644 index 663982b1..00000000 --- a/backend/src/data/ApiRoles.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum ApiRoles { - Viewer = 1, - Editor, - Manager, - ServerOwner, -} diff --git a/backend/src/data/entities/ApiPermission.ts b/backend/src/data/entities/ApiPermissionAssignment.ts similarity index 63% rename from backend/src/data/entities/ApiPermission.ts rename to backend/src/data/entities/ApiPermissionAssignment.ts index b648eb27..b97ce26b 100644 --- a/backend/src/data/entities/ApiPermission.ts +++ b/backend/src/data/entities/ApiPermissionAssignment.ts @@ -2,19 +2,23 @@ import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from "typeorm"; import { ApiUserInfo } from "./ApiUserInfo"; @Entity("api_permissions") -export class ApiPermission { +export class ApiPermissionAssignment { @Column() @PrimaryColumn() guild_id: string; @Column() @PrimaryColumn() - user_id: string; + type: string; @Column() - role: string; + @PrimaryColumn() + target_id: string; - @ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissions) - @JoinColumn({ name: "user_id" }) + @Column("simple-array") + permissions: string[]; + + @ManyToOne(type => ApiUserInfo, userInfo => userInfo.permissionAssignments) + @JoinColumn({ name: "target_id" }) userInfo: ApiUserInfo; } diff --git a/backend/src/data/entities/ApiUserInfo.ts b/backend/src/data/entities/ApiUserInfo.ts index f39addf2..9d36eb2c 100644 --- a/backend/src/data/entities/ApiUserInfo.ts +++ b/backend/src/data/entities/ApiUserInfo.ts @@ -1,6 +1,6 @@ import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm"; import { ApiLogin } from "./ApiLogin"; -import { ApiPermission } from "./ApiPermission"; +import { ApiPermissionAssignment } from "./ApiPermissionAssignment"; export interface ApiUserInfoData { username: string; @@ -23,6 +23,6 @@ export class ApiUserInfo { @OneToMany(type => ApiLogin, login => login.userInfo) logins: ApiLogin[]; - @OneToMany(type => ApiPermission, perm => perm.userInfo) - permissions: ApiPermission[]; + @OneToMany(type => ApiPermissionAssignment, p => p.userInfo) + permissionAssignments: ApiPermissionAssignment[]; } diff --git a/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts b/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts new file mode 100644 index 00000000..d6de7ffd --- /dev/null +++ b/backend/src/migrations/1573158035867-AddTypeAndPermissionsToApiPermissions.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; + +export class AddTypeAndPermissionsToApiPermissions1573158035867 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.dropPrimaryKey("api_permissions"); + await queryRunner.dropIndex("api_permissions", "IDX_5e371749d4cb4a5191f35e26f6"); + + await queryRunner.addColumn( + "api_permissions", + new TableColumn({ + name: "type", + type: "varchar", + length: "16", + }), + ); + + await queryRunner.renameColumn("api_permissions", "user_id", "target_id"); + + await queryRunner.createPrimaryKey("api_permissions", ["guild_id", "type", "target_id"]); + + await queryRunner.dropColumn("api_permissions", "role"); + + await queryRunner.addColumn( + "api_permissions", + new TableColumn({ + name: "permissions", + type: "text", + }), + ); + + await queryRunner.query(` + UPDATE api_permissions + SET type="USER", + permissions="EDIT_CONFIG" + `); + + await queryRunner.createIndex( + "api_permissions", + new TableIndex({ + columnNames: ["type", "target_id"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex("api_permissions", "IDX_e06d750f13e6a4b4d3d6b847a9"); + + await queryRunner.dropColumn("api_permissions", "permissions"); + + await queryRunner.addColumn( + "api_permissions", + new TableColumn({ + name: "role", + type: "varchar", + length: "32", + }), + ); + + await queryRunner.dropPrimaryKey("api_permissions"); + + await queryRunner.renameColumn("api_permissions", "target_id", "user_id"); + + await queryRunner.dropColumn("api_permissions", "type"); + + await queryRunner.createIndex( + "api_permissions", + new TableIndex({ + columnNames: ["user_id"], + }), + ); + + await queryRunner.createPrimaryKey("api_permissions", ["guild_id", "user_id"]); + } +} diff --git a/dashboard/src/components/dashboard/GuildAccess.vue b/dashboard/src/components/dashboard/GuildAccess.vue index 580a5951..72e5bbe7 100644 --- a/dashboard/src/components/dashboard/GuildAccess.vue +++ b/dashboard/src/components/dashboard/GuildAccess.vue @@ -4,5 +4,36 @@

Or here

+ + + diff --git a/dashboard/src/components/dashboard/PermissionTree.vue b/dashboard/src/components/dashboard/PermissionTree.vue new file mode 100644 index 00000000..1f8a13a4 --- /dev/null +++ b/dashboard/src/components/dashboard/PermissionTree.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/dashboard/src/components/dashboard/permissionTreeUtils.ts b/dashboard/src/components/dashboard/permissionTreeUtils.ts new file mode 100644 index 00000000..0051ff60 --- /dev/null +++ b/dashboard/src/components/dashboard/permissionTreeUtils.ts @@ -0,0 +1,43 @@ +import { ApiPermissions, hasPermission, TPermissionHierarchy } from "@shared/apiPermissions"; + +export type TPermissionHierarchyState = { + locked: boolean; + redundant: boolean; +}; + +export type TApiPermissionWithState = [ApiPermissions, TPermissionHierarchyState, TPermissionHierarchyWithState?]; +export type TPermissionHierarchyWithState = TApiPermissionWithState[]; + +/** + * @param tree + * @param grantedPermissions Permissions granted to the user being edited + * @param managerPermissions Permissions granted to the user who's editing the other user's permissions + * @param entireTreeIsGranted + */ +export function applyStateToPermissionHierarchy( + tree: TPermissionHierarchy, + grantedPermissions: Set, + managerPermissions: Set = new Set(), + entireTreeIsGranted = false, +): TPermissionHierarchyWithState { + const result: TPermissionHierarchyWithState = []; + + for (const item of tree) { + const [perm, nested] = Array.isArray(item) ? item : [item]; + + // Can't edit permissions you don't have yourself + const locked = !hasPermission(managerPermissions, perm); + const permissionWithState: TApiPermissionWithState = [perm, { locked, redundant: entireTreeIsGranted }]; + + if (nested) { + const subTreeGranted = entireTreeIsGranted || grantedPermissions.has(perm); + permissionWithState.push( + applyStateToPermissionHierarchy(nested, grantedPermissions, managerPermissions, subTreeGranted), + ); + } + + result.push(permissionWithState); + } + + return result; +} diff --git a/shared/src/apiPermissions.test.ts b/shared/src/apiPermissions.test.ts index 51c480ed..b0945fe8 100644 --- a/shared/src/apiPermissions.test.ts +++ b/shared/src/apiPermissions.test.ts @@ -2,12 +2,12 @@ import { ApiPermissions, hasPermission } from "./apiPermissions"; import test from "ava"; test("Directly granted permissions match", t => { - t.is(hasPermission([ApiPermissions.ManageAccess], ApiPermissions.ManageAccess), true); - t.is(hasPermission([ApiPermissions.ManageAccess], ApiPermissions.Owner), false); + t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.ManageAccess), true); + t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.Owner), false); }); test("Implicitly granted permissions by hierarchy match", t => { - t.is(hasPermission([ApiPermissions.ManageAccess], ApiPermissions.EditConfig), true); - t.is(hasPermission([ApiPermissions.ManageAccess], ApiPermissions.ReadConfig), true); - t.is(hasPermission([ApiPermissions.EditConfig], ApiPermissions.ManageAccess), false); + t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.EditConfig), true); + t.is(hasPermission(new Set([ApiPermissions.ManageAccess]), ApiPermissions.ReadConfig), true); + t.is(hasPermission(new Set([ApiPermissions.EditConfig]), ApiPermissions.ManageAccess), false); }); diff --git a/shared/src/apiPermissions.ts b/shared/src/apiPermissions.ts index a4ac822a..bf96ea84 100644 --- a/shared/src/apiPermissions.ts +++ b/shared/src/apiPermissions.ts @@ -5,26 +5,36 @@ export enum ApiPermissions { ReadConfig = "READ_CONFIG", } -interface IPermissionHierarchy extends Partial> {} +const reverseApiPermissions = Object.entries(ApiPermissions).reduce((map, [key, value]) => { + map[value] = key; + return map; +}, {}); -export const permissionHierarchy: IPermissionHierarchy = { - [ApiPermissions.Owner]: { - [ApiPermissions.ManageAccess]: { - [ApiPermissions.EditConfig]: { - [ApiPermissions.ReadConfig]: {}, - }, - }, - }, +export const permissionNames = { + [ApiPermissions.Owner]: "Server owner", + [ApiPermissions.ManageAccess]: "Manage dashboard access", + [ApiPermissions.EditConfig]: "Edit config", + [ApiPermissions.ReadConfig]: "Read config", }; +export type TPermissionHierarchy = Array; + +export const permissionHierarchy: TPermissionHierarchy = [ + [ApiPermissions.Owner, [[ApiPermissions.ManageAccess, [[ApiPermissions.EditConfig, [ApiPermissions.ReadConfig]]]]]], +]; + +export function permissionArrToSet(permissions: string[]): Set { + return new Set(permissions.filter(p => reverseApiPermissions[p])) as Set; +} + /** * Checks whether granted permissions include the specified permission, taking into account permission hierarchy i.e. * that in the case of nested permissions, having a top level permission implicitly grants you any permissions nested * under it as well */ -export function hasPermission(grantedPermissions: ApiPermissions[], permissionToCheck: ApiPermissions): boolean { +export function hasPermission(grantedPermissions: Set, permissionToCheck: ApiPermissions): boolean { // Directly granted - if (grantedPermissions.includes(permissionToCheck)) { + if (grantedPermissions.has(permissionToCheck)) { return true; } @@ -37,15 +47,17 @@ export function hasPermission(grantedPermissions: ApiPermissions[], permissionTo } function checkTreeForPermission( - tree: IPermissionHierarchy, - grantedPermissions: ApiPermissions[], + tree: TPermissionHierarchy, + grantedPermissions: Set, permission: ApiPermissions, ): boolean { - for (const [perm, nested] of Object.entries(tree)) { + for (const item of tree) { + const [perm, nested] = Array.isArray(item) ? item : [item]; + // Top-level permission granted, implicitly grant all nested permissions as well - if (grantedPermissions.includes(perm as ApiPermissions)) { + if (grantedPermissions.has(perm)) { // Permission we were looking for was found nested under this permission -> granted - if (treeIncludesPermission(nested, permission)) { + if (nested && treeIncludesPermission(nested, permission)) { return true; } @@ -55,7 +67,7 @@ function checkTreeForPermission( } // Top-level permission not granted, check further nested permissions - if (checkTreeForPermission(nested, grantedPermissions, permission)) { + if (nested && checkTreeForPermission(nested, grantedPermissions, permission)) { return true; } } @@ -63,13 +75,15 @@ function checkTreeForPermission( return false; } -function treeIncludesPermission(tree: IPermissionHierarchy, permission: ApiPermissions): boolean { - for (const [perm, nested] of Object.entries(tree)) { +function treeIncludesPermission(tree: TPermissionHierarchy, permission: ApiPermissions): boolean { + for (const item of tree) { + const [perm, nested] = Array.isArray(item) ? item : [item]; + if (perm === permission) { return true; } - const nestedResult = treeIncludesPermission(nested, permission); + const nestedResult = nested && treeIncludesPermission(nested, permission); if (nestedResult) { return true; }