From 3b09d2d679017289a905fb99a164f6e3d76a50ae Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 5 Sep 2021 16:42:35 +0300 Subject: [PATCH] Add rudimentary user management to dashboard --- backend/src/api/auth.ts | 2 +- backend/src/api/guilds.ts | 76 +++++- backend/src/data/ApiPermissionAssignments.ts | 16 +- .../data/entities/ApiPermissionAssignment.ts | 6 +- ...0837386329-AddExpiresAtToApiPermissions.ts | 2 +- dashboard/package-lock.json | 25 ++ dashboard/package.json | 2 + .../src/components/dashboard/GuildAccess.vue | 237 +++++++++++++++--- .../src/components/dashboard/GuildList.vue | 25 +- dashboard/src/store/auth.ts | 14 +- dashboard/src/store/guilds.ts | 51 ++-- dashboard/src/store/types.ts | 20 +- 12 files changed, 395 insertions(+), 81 deletions(-) diff --git a/backend/src/api/auth.ts b/backend/src/api/auth.ts index c891ac63..92e8e594 100644 --- a/backend/src/api/auth.ts +++ b/backend/src/api/auth.ts @@ -149,7 +149,7 @@ export function initAuth(app: express.Express) { return res.json({ valid: false }); } - res.json({ valid: true }); + res.json({ valid: true, userId }); }); app.post("/auth/logout", ...apiTokenAuthHandlers(), async (req: Request, res: Response) => { await apiLogins.expireApiKey(req.user!.apiKey); diff --git a/backend/src/api/guilds.ts b/backend/src/api/guilds.ts index 6f6a7a85..412f526f 100644 --- a/backend/src/api/guilds.ts +++ b/backend/src/api/guilds.ts @@ -3,15 +3,21 @@ import express, { Request, Response } from "express"; import { YAMLException } from "js-yaml"; import { validateGuildConfig } from "../configValidator"; import { AllowedGuilds } from "../data/AllowedGuilds"; -import { ApiPermissionAssignments } from "../data/ApiPermissionAssignments"; +import { ApiPermissionAssignments, ApiPermissionTypes } from "../data/ApiPermissionAssignments"; import { Configs } from "../data/Configs"; import { apiTokenAuthHandlers } from "./auth"; import { hasGuildPermission, requireGuildPermission } from "./permissions"; import { clientError, ok, serverError, unauthorized } from "./responses"; import { loadYamlSafely } from "../utils/loadYamlSafely"; import { ObjectAliasError } from "../utils/validateNoObjectAliases"; +import { isSnowflake } from "../utils"; +import moment from "moment-timezone"; +import { ApiAuditLog } from "../data/ApiAuditLog"; +import { AuditLogEventTypes } from "../data/apiAuditLogTypes"; +import { Queue } from "../Queue"; const apiPermissionAssignments = new ApiPermissionAssignments(); +const auditLog = new ApiAuditLog(); export function initGuildsAPI(app: express.Express) { const allowedGuilds = new AllowedGuilds(); @@ -25,6 +31,14 @@ export function initGuildsAPI(app: express.Express) { res.json(guilds); }); + guildRouter.get( + "/my-permissions", // a + async (req: Request, res: Response) => { + const permissions = await apiPermissionAssignments.getByUserId(req.user!.userId); + res.json(permissions); + }, + ); + guildRouter.get("/:guildId", async (req: Request, res: Response) => { if (!(await hasGuildPermission(req.user!.userId, req.params.guildId, ApiPermissions.ViewGuild))) { return unauthorized(res); @@ -101,5 +115,65 @@ export function initGuildsAPI(app: express.Express) { }, ); + const permissionManagementQueue = new Queue(); + guildRouter.post( + "/:guildId/set-target-permissions", + requireGuildPermission(ApiPermissions.ManageAccess), + async (req: Request, res: Response) => { + await permissionManagementQueue.add(async () => { + const { type, targetId, permissions, expiresAt } = req.body; + + if (type !== ApiPermissionTypes.User) { + return clientError(res, "Invalid type"); + } + if (!isSnowflake(targetId)) { + return clientError(res, "Invalid targetId"); + } + const validPermissions = new Set(Object.values(ApiPermissions)); + validPermissions.delete(ApiPermissions.Owner); + if (!Array.isArray(permissions) || permissions.some(p => !validPermissions.has(p))) { + return clientError(res, "Invalid permissions"); + } + if (expiresAt != null && !moment.utc(expiresAt).isValid()) { + return clientError(res, "Invalid expiresAt"); + } + + const existingAssignment = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existingAssignment && existingAssignment.permissions.includes(ApiPermissions.Owner)) { + return clientError(res, "Can't change owner permissions"); + } + + if (permissions.length === 0) { + await apiPermissionAssignments.removeUser(req.params.guildId, targetId); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.REMOVE_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + }); + } else { + const existing = await apiPermissionAssignments.getByGuildAndUserId(req.params.guildId, targetId); + if (existing) { + await apiPermissionAssignments.updateUserPermissions(req.params.guildId, targetId, permissions); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.EDIT_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: existing.expires_at, + }); + } else { + await apiPermissionAssignments.addUser(req.params.guildId, targetId, permissions, expiresAt); + await auditLog.addEntry(req.params.guildId, req.user!.userId, AuditLogEventTypes.ADD_API_PERMISSION, { + type: ApiPermissionTypes.User, + target_id: targetId, + permissions, + expires_at: expiresAt, + }); + } + } + + ok(res); + }); + }, + ); + app.use("/guilds", guildRouter); } diff --git a/backend/src/data/ApiPermissionAssignments.ts b/backend/src/data/ApiPermissionAssignments.ts index 2497bf49..d6cf11db 100644 --- a/backend/src/data/ApiPermissionAssignments.ts +++ b/backend/src/data/ApiPermissionAssignments.ts @@ -48,12 +48,13 @@ export class ApiPermissionAssignments extends BaseRepository { }); } - addUser(guildId, userId, permissions: ApiPermissions[]) { + addUser(guildId, userId, permissions: ApiPermissions[], expiresAt: string | null = null) { return this.apiPermissions.insert({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId, permissions, + expires_at: expiresAt, }); } @@ -61,6 +62,19 @@ export class ApiPermissionAssignments extends BaseRepository { return this.apiPermissions.delete({ guild_id: guildId, type: ApiPermissionTypes.User, target_id: userId }); } + async updateUserPermissions(guildId: string, userId: string, permissions: ApiPermissions[]): Promise { + await this.apiPermissions.update( + { + guild_id: guildId, + type: ApiPermissionTypes.User, + target_id: userId, + }, + { + permissions, + }, + ); + } + async clearExpiredPermissions() { await this.apiPermissions .createQueryBuilder() diff --git a/backend/src/data/entities/ApiPermissionAssignment.ts b/backend/src/data/entities/ApiPermissionAssignment.ts index 0171f940..349544d5 100644 --- a/backend/src/data/entities/ApiPermissionAssignment.ts +++ b/backend/src/data/entities/ApiPermissionAssignment.ts @@ -8,7 +8,7 @@ export class ApiPermissionAssignment { @PrimaryColumn() guild_id: string; - @Column({ type: "string" }) + @Column({ type: String }) @PrimaryColumn() type: ApiPermissionTypes; @@ -19,8 +19,8 @@ export class ApiPermissionAssignment { @Column("simple-array") permissions: string[]; - @Column() - expires_at: string; + @Column({ type: String, nullable: true }) + expires_at: string | null; @ManyToOne( type => ApiUserInfo, diff --git a/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts b/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts index 3eb6ea27..812d35ed 100644 --- a/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts +++ b/backend/src/migrations/1630837386329-AddExpiresAtToApiPermissions.ts @@ -5,7 +5,7 @@ export class AddExpiresAtToApiPermissions1630837386329 implements MigrationInter await queryRunner.addColumns("api_permissions", [ new TableColumn({ name: "expires_at", - type: "boolean", + type: "datetime", isNullable: true, default: null, }), diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index f395a81f..7aeb3c64 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "dependencies": { "highlight.js": "^9.15.10", + "humanize-duration": "^3.27.0", "js-yaml": "^3.13.1", "marked": "^0.7.0", "modern-css-reset": "^1.0.4", + "moment": "^2.29.1", "vue": "^2.6.10", "vue-highlightjs": "git://github.com/Dragory/vue-highlightjs.git#pass-hljs-instance", "vue-material-design-icons": "^4.1.0", @@ -6038,6 +6040,11 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "node_modules/humanize-duration": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.0.tgz", + "integrity": "sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -7164,6 +7171,14 @@ "resolved": "https://registry.npmjs.org/modern-css-reset/-/modern-css-reset-1.4.0.tgz", "integrity": "sha512-0crZmSFmrxkI7159rvQWjpDhy0u4+Awg/iOycJdlVn0RSeft/a+6BrQHR3IqvmdK25sqt0o6Z5Ap7cWgUee2rw==" }, + "node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "engines": { + "node": "*" + } + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -17836,6 +17851,11 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "humanize-duration": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.0.tgz", + "integrity": "sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -18685,6 +18705,11 @@ "resolved": "https://registry.npmjs.org/modern-css-reset/-/modern-css-reset-1.4.0.tgz", "integrity": "sha512-0crZmSFmrxkI7159rvQWjpDhy0u4+Awg/iOycJdlVn0RSeft/a+6BrQHR3IqvmdK25sqt0o6Z5Ap7cWgUee2rw==" }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 01b2e226..2a3ae71f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -39,9 +39,11 @@ }, "dependencies": { "highlight.js": "^9.15.10", + "humanize-duration": "^3.27.0", "js-yaml": "^3.13.1", "marked": "^0.7.0", "modern-css-reset": "^1.0.4", + "moment": "^2.29.1", "vue": "^2.6.10", "vue-highlightjs": "git://github.com/Dragory/vue-highlightjs.git#pass-hljs-instance", "vue-material-design-icons": "^4.1.0", diff --git a/dashboard/src/components/dashboard/GuildAccess.vue b/dashboard/src/components/dashboard/GuildAccess.vue index e19acde1..c4aa9b9d 100644 --- a/dashboard/src/components/dashboard/GuildAccess.vue +++ b/dashboard/src/components/dashboard/GuildAccess.vue @@ -1,70 +1,182 @@ diff --git a/dashboard/src/components/dashboard/GuildList.vue b/dashboard/src/components/dashboard/GuildList.vue index 3b126ce6..2842d7f3 100644 --- a/dashboard/src/components/dashboard/GuildList.vue +++ b/dashboard/src/components/dashboard/GuildList.vue @@ -17,9 +17,8 @@
{{ guild.id }}
- Info Config - Access + Access
@@ -28,12 +27,15 @@ - diff --git a/dashboard/src/store/auth.ts b/dashboard/src/store/auth.ts index 4e24658c..457284b3 100644 --- a/dashboard/src/store/auth.ts +++ b/dashboard/src/store/auth.ts @@ -12,6 +12,7 @@ export const AuthStore: Module = { apiKey: null, loadedInitialAuth: false, authRefreshInterval: null, + userId: null, }, actions: { @@ -23,7 +24,7 @@ export const AuthStore: Module = { try { const result = await post("auth/validate-key", { key: storedKey }); if (result.valid) { - await dispatch("setApiKey", storedKey); + await dispatch("setApiKey", { key: storedKey, userId: result.userId }); return; } } catch {} // tslint:disable-line @@ -35,9 +36,9 @@ export const AuthStore: Module = { commit("markInitialAuthLoaded"); }, - setApiKey({ commit, state, dispatch }, newKey: string) { - localStorage.setItem("apiKey", newKey); - commit("setApiKey", newKey); + setApiKey({ commit, state, dispatch }, { key, userId }) { + localStorage.setItem("apiKey", key); + commit("setApiKey", { key, userId }); dispatch("startAuthAutoRefresh"); }, @@ -64,7 +65,7 @@ export const AuthStore: Module = { await dispatch("endAuthAutoRefresh"); localStorage.removeItem("apiKey"); - commit("setApiKey", null); + commit("setApiKey", { key: null, userId: null }); }, async logout({ dispatch }) { @@ -79,8 +80,9 @@ export const AuthStore: Module = { }, mutations: { - setApiKey(state: AuthState, key) { + setApiKey(state: AuthState, { key, userId }) { state.apiKey = key; + state.userId = userId; }, setAuthRefreshInterval(state: AuthState, interval: IntervalType | null) { diff --git a/dashboard/src/store/guilds.ts b/dashboard/src/store/guilds.ts index ce9306dc..dfe6ac43 100644 --- a/dashboard/src/store/guilds.ts +++ b/dashboard/src/store/guilds.ts @@ -11,7 +11,6 @@ export const GuildStore: Module = { availableGuildsLoadStatus: LoadStatus.None, available: new Map(), configs: {}, - myPermissions: {}, guildPermissionAssignments: {}, }, @@ -48,9 +47,14 @@ export const GuildStore: Module = { await post(`guilds/${guildId}/config`, { config }); }, - async checkPermission({ commit }, { guildId, permission }) { - const result = await post(`guilds/${guildId}/check-permission`, { permission }); - commit("setMyPermission", { guildId, permission, value: result.result }); + async loadMyPermissionAssignments({ commit }) { + const myPermissionAssignments = await get(`guilds/my-permissions`); + for (const permissionAssignment of myPermissionAssignments) { + commit("setGuildPermissionAssignments", { + guildId: permissionAssignment.guild_id, + permissionAssignments: [permissionAssignment], + }); + } }, async loadGuildPermissionAssignments({ commit }, guildId) { @@ -58,8 +62,9 @@ export const GuildStore: Module = { commit("setGuildPermissionAssignments", { guildId, permissionAssignments }); }, - async setTargetPermissions({ commit }, { guildId, targetId, type, permissions }) { - commit("setTargetPermissions", { guildId, targetId, type, permissions }); + async setTargetPermissions({ commit }, { guildId, targetId, type, permissions, expiresAt }) { + await post(`guilds/${guildId}/set-target-permissions`, { guildId, targetId, type, permissions, expiresAt }); + commit("setTargetPermissions", { guildId, targetId, type, permissions, expiresAt }); }, }, @@ -77,12 +82,11 @@ export const GuildStore: Module = { Vue.set(state.configs, guildId, config); }, - setMyPermission(state: GuildState, { guildId, permission, value }) { - Vue.set(state.myPermissions, guildId, state.myPermissions[guildId] || {}); - Vue.set(state.myPermissions[guildId], permission, value); - }, - setGuildPermissionAssignments(state: GuildState, { guildId, permissionAssignments }) { + if (!state.guildPermissionAssignments) { + Vue.set(state, "guildPermissionAssignments", {}); + } + Vue.set( state.guildPermissionAssignments, guildId, @@ -93,12 +97,29 @@ export const GuildStore: Module = { ); }, - setTargetPermissions(state: GuildState, { guildId, targetId, type, permissions }) { + setTargetPermissions(state: GuildState, { guildId, targetId, type, permissions, expiresAt }) { const guildPermissionAssignments = state.guildPermissionAssignments[guildId] || []; - const itemToEdit = guildPermissionAssignments.find(p => p.target_id === targetId && p.type === type); - if (!itemToEdit) return; + if (permissions.length === 0) { + // No permissions -> remove permission assignment + guildPermissionAssignments.splice( + guildPermissionAssignments.findIndex(p => p.target_id === targetId && p.type === type), + 1, + ); + } else { + // Update/add permission assignment + const itemToEdit = guildPermissionAssignments.find(p => p.target_id === targetId && p.type === type); + if (itemToEdit) { + itemToEdit.permissions = new Set(permissions); + } else { + state.guildPermissionAssignments[guildId].push({ + type, + target_id: targetId, + permissions: new Set(permissions), + expires_at: expiresAt, + }); + } + } - itemToEdit.permissions = permissions; state.guildPermissionAssignments = { ...state.guildPermissionAssignments }; }, }, diff --git a/dashboard/src/store/types.ts b/dashboard/src/store/types.ts index b11193aa..c7e2987b 100644 --- a/dashboard/src/store/types.ts +++ b/dashboard/src/store/types.ts @@ -1,5 +1,4 @@ import { ApiPermissions } from "@shared/apiPermissions"; -import { ApiPermissionTypes } from "../../../backend/src/data/ApiPermissionAssignments"; export enum LoadStatus { None = 1, @@ -14,6 +13,14 @@ export interface AuthState { apiKey: string | null; loadedInitialAuth: boolean; authRefreshInterval: IntervalType | null; + userId: string | null; +} + +export interface GuildPermissionAssignment { + type: string; + target_id: string; + permissions: Set; + expires_at: string | null; } export interface GuildState { @@ -29,17 +36,8 @@ export interface GuildState { configs: { [key: string]: string; }; - myPermissions: { - [guildId: string]: { - [K in ApiPermissions]?: boolean; - }; - }; guildPermissionAssignments: { - [guildId: string]: Array<{ - target_id: string; - type: ApiPermissionTypes; - permissions: Set; - }>; + [guildId: string]: GuildPermissionAssignment[]; }; }