diff --git a/.gitignore b/.gitignore index dbbc19e20..0e6733946 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ config/local.json config/areas.json !server/src/configs/.gitkeep .env +ecosystem.config.js # Masterfile server/src/data/masterfile.json diff --git a/config/local.example.json b/config/local.example.json index 385b3bdf5..3f77805fd 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -94,6 +94,10 @@ { "roles": [], "areas": [] + }, + { + "users": [], + "areas": [] } ], "aliases": [ @@ -104,6 +108,10 @@ { "role": "897581458191", "name": "supporter" + }, + { + "user": "112233445566778899", + "name": "special_user" } ], "alwaysEnabledPerms": [], diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index baa4fdc56..4f259555f 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -274,55 +274,91 @@ const applyMutations = (config) => { : { little: false, cap: 500 } } - const aliasObj = Object.fromEntries( - config.authentication.aliases.map((alias) => [alias.name, alias.role]), + /** + * @param {unknown} value + * @returns {string[]} + */ + const toStringArray = (value) => + (Array.isArray(value) ? value : [value]) + .filter((item) => item !== undefined && item !== null) + .map((item) => `${item}`) + + const roleAliasMap = Object.fromEntries( + config.authentication.aliases + .filter((alias) => alias.role !== undefined) + .map((alias) => [alias.name, toStringArray(alias.role)]), + ) + const userAliasMap = Object.fromEntries( + config.authentication.aliases + .filter((alias) => alias.user !== undefined) + .map((alias) => [alias.name, toStringArray(alias.user)]), ) - /** @param {string | string[]} role */ - const replaceAliases = (role) => - Array.isArray(role) - ? role.flatMap((r) => aliasObj[r] ?? r) - : (aliasObj[role] ?? role) + + /** + * @param {unknown} value + * @returns {string[]} + */ + const replaceRoleAliases = (value) => + toStringArray(value).flatMap((item) => roleAliasMap[item] ?? [item]) + + /** + * @param {unknown} value + * @returns {string[]} + */ + const replaceUserAliases = (value) => + toStringArray(value).flatMap((item) => userAliasMap[item] ?? [item]) const replaceBothAliases = (incomingObj) => ({ ...incomingObj, discordRoles: Array.isArray(incomingObj.discordRoles) - ? incomingObj.discordRoles.flatMap(replaceAliases) + ? incomingObj.discordRoles.flatMap(replaceRoleAliases) : undefined, telegramGroups: Array.isArray(incomingObj.telegramGroups) - ? incomingObj.telegramGroups.flatMap(replaceAliases) + ? incomingObj.telegramGroups.flatMap(replaceRoleAliases) : undefined, }) Object.keys(config.authentication.perms).forEach((perm) => { config.authentication.perms[perm].roles = - config.authentication.perms[perm].roles.flatMap(replaceAliases) + config.authentication.perms[perm].roles.flatMap(replaceRoleAliases) }) config.authentication.areaRestrictions = - config.authentication.areaRestrictions.map(({ roles, areas }) => ({ - roles: roles.flatMap(replaceAliases), - areas, - })) + config.authentication.areaRestrictions.map((restriction) => { + const roles = Array.isArray(restriction.roles) + ? restriction.roles + : restriction.roles !== undefined && restriction.roles !== null + ? [restriction.roles] + : [] + const next = { + roles: roles.flatMap(replaceRoleAliases), + areas: Array.isArray(restriction.areas) ? restriction.areas : [], + } + if (Array.isArray(restriction.users)) { + next.users = restriction.users.flatMap(replaceUserAliases) + } + return next + }) config.authentication.strategies = config.authentication.strategies.map( (strategy) => ({ ...strategy, allowedGuilds: Array.isArray(strategy.allowedGuilds) - ? strategy.allowedGuilds.flatMap(replaceAliases) + ? strategy.allowedGuilds.flatMap(replaceRoleAliases) : [], blockedGuilds: Array.isArray(strategy.blockedGuilds) - ? strategy.blockedGuilds.flatMap(replaceAliases) + ? strategy.blockedGuilds.flatMap(replaceRoleAliases) : [], groups: Array.isArray(strategy.groups) - ? strategy.groups.flatMap(replaceAliases) + ? strategy.groups.flatMap(replaceRoleAliases) : [], allowedUsers: Array.isArray(strategy.allowedUsers) - ? strategy.allowedUsers.flatMap(replaceAliases) + ? strategy.allowedUsers.flatMap(replaceUserAliases) : [], trialPeriod: { ...strategy.trialPeriod, roles: Array.isArray(strategy?.trialPeriod?.roles) - ? strategy.trialPeriod.roles.flatMap(replaceAliases) + ? strategy.trialPeriod.roles.flatMap(replaceRoleAliases) : [], }, }), diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 3c17ecf67..6b2e251f6 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -53,13 +53,21 @@ export type Config = DeepMerge< } areas: ConfigAreas authentication: { - areaRestrictions: { roles: string[]; areas: string[] }[] + areaRestrictions: { + roles: string[] + users?: string[] + areas: string[] + }[] // Unfortunately these types are not convenient for looping the `perms` object... // excludeFromTutorial: (keyof BaseConfig['authentication']['perms'])[] // alwaysEnabledPerms: (keyof BaseConfig['authentication']['perms'])[] excludeFromTutorial: string[] alwaysEnabledPerms: string[] - aliases: { role: string | string[]; name: string }[] + aliases: { + name: string + role?: string | string[] + user?: string | string[] + }[] methods: Strategy[] strategies: { type: Strategy diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 036e986ad..0844d839c 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -109,6 +109,62 @@ class DiscordClient extends AuthClient { return [] } + /** + * Resolve user-specific area restrictions. + * @param {string} userId + * @returns {{ areas: string[]; reset: boolean }} + */ + // static so it remains lint-compliant and easily testable + static getUserAreaRestrictionsFromConfig( + userId, + authentication, + areasConfig, + ) { + const { areaRestrictions = [] } = authentication || {} + if (!userId || !areaRestrictions.length) { + return { areas: [], reset: false } + } + const matchedAreas = new Set() + const userIdStr = `${userId}` + + for (let i = 0; i < areaRestrictions.length; i += 1) { + const restriction = areaRestrictions[i] + if (!Array.isArray(restriction.users)) continue + + const matchesUser = restriction.users.some( + (user) => `${user}` === userIdStr, + ) + if (!matchesUser) continue + + if (!restriction.areas?.length) { + return { areas: [], reset: true } + } + + for (let j = 0; j < restriction.areas.length; j += 1) { + const areaName = restriction.areas[j] + if (areasConfig.names.has(areaName)) { + matchedAreas.add(areaName) + } else if (areasConfig.withoutParents[areaName]) { + areasConfig.withoutParents[areaName].forEach((child) => + matchedAreas.add(child), + ) + } + } + } + + return { areas: [...matchedAreas], reset: false } + } + + static getUserAreaRestrictions(userId) { + const authentication = config.getSafe('authentication') + const areasConfig = config.getSafe('areas') + return DiscordClient.getUserAreaRestrictionsFromConfig( + userId, + authentication, + areasConfig, + ) + } + /** * * @param {import('passport-discord').Profile} user @@ -116,6 +172,7 @@ class DiscordClient extends AuthClient { */ async getPerms(user) { const trialActive = this.trialManager.active() + const userAreaPerms = DiscordClient.getUserAreaRestrictions(user.id) /** @type {import("@rm/types").Permissions} */ // @ts-ignore const perms = Object.fromEntries( @@ -204,6 +261,10 @@ class DiscordClient extends AuthClient { } catch (e) { this.log.warn('Failed to get perms for user', user.id, e) } + if (userAreaPerms.reset) { + permSets.areaRestrictions.clear() + } + userAreaPerms.areas.forEach((area) => permSets.areaRestrictions.add(area)) Object.entries(permSets).forEach(([key, value]) => { perms[key] = [...value] })