diff --git a/Docs/Plans/Permission_Audit_Plan.md b/Docs/Plans/Permission_Audit_Plan.md new file mode 100644 index 0000000..eb2dd88 --- /dev/null +++ b/Docs/Plans/Permission_Audit_Plan.md @@ -0,0 +1,140 @@ +# 권한 감사 기능 기획서 (Permission Audit Plan) + +## 체인지로그 (Changelog) +- **2026-03-27**: 최초 작성 + +--- + +## 1. 개요 (Overview) + +| 항목 | 내용 | +|------|------| +| **목표** | 봇이 각 기능을 정상적으로 수행하기 위해 필요한 권한을 진단하고, 부족한 권한이 미치는 영향을 관리자에게 안내 | +| **트리거** | 슬래시 명령어 (`/audit-permissions`) | +| **대상** | 서버(Guild) 관리자 전용 | +| **응답 형태** | Ephemeral Embed | + +### 설계 원칙 + +- **변수 최소화**: 외부 환경(카테고리 오버라이드, 역할 계층 등) 에 의한 예측 불가 실패를 사전에 탐지 +- **확장 가능성**: 향후 새 기능이 추가될 때, **권한 항목 정의만 추가**하면 자동으로 진단 대상에 포함되는 플러그인 방식 채택 +- **영향도 우선 안내**: 단순히 권한 목록 나열이 아닌, "이 권한 없으면 어떤 기능이 안 된다"는 영향도(Consequence) 중심으로 안내 + +--- + +## 2. 권한-기능 매핑 테이블 (Permission Mapping) + +> 아래는 현재 구현된 기능 기준이며, 향후 기능 추가 시 이 테이블에 **행을 추가**하여 확장합니다. + +| 기능 분류 | 범주 | 필수 권한 | 누락 시 비활성화되는 기능 | +| :--- | :---: | :--- | :--- | +| **공통 (Basic)** | 전역 | `View Channel` | 봇의 모든 채널 접근 불가 | +| | | `Send Messages` | 모든 봇 응답 발송 불가 | +| | | `Embed Links` | Embed 형태의 메시지 전송 불가 | +| **임시 음성 채널** | 전역 | `Manage Channels` | 임시 채널 생성·삭제 불가 | +| | | `Move Members` | 생성기 입장 시 임시 채널로 자동 이동 불가 | +| | | `Manage Roles` | 채널 잠금, 유저 차단 등 권한 오버라이드 불가 | +| | | `Connect` | 음성 채널 접근 불가 | +| **초대 추적** | 전역 | `Manage Guild` | 서버 초대 목록 조회 불가 → 초대자 추적 전체 중단 | +| **역할 자동 부여** | 전역 | `Manage Roles` | 초대코드 기반 역할 자동 부여 불가 | +| | **계층** | `botHighestRole > 대상 역할` | 봇 역할이 대상 역할보다 낮으면 역할 부여 실패 | +| **메시지 흉내 (Mimic)** | 채널 | `Manage Webhooks` | 유저 프로필 복제 메시지 전송 불가 | +| | | `Manage Messages` | 원본 메시지 삭제 불가 (중복 노출) | +| *(향후) 감사 채널* | 전역 | `Send Messages`, `Embed Links` | 지정된 감사 채널에 로그 기록 불가 | +| *(향후) 설정 도우미* | 전역 | `Manage Channels`, `Manage Roles` | 설정 마법사 중 채널·역할 자동 생성 단계 실패 | + +--- + +## 3. 점검 범위 (Audit Scope) + +### 3.1. 전역 권한 (Guild-level) +봇의 통합 역할에 부여된 기본 권한을 확인합니다. + +### 3.2. 채널 및 카테고리 오버라이드 (Channel Override) +- 음성 채널 생성기(`VoiceGenerator`)가 속한 **카테고리**에서 봇 권한이 `Deny`로 오버라이드되어 있는지 확인 +- 전역 권한이 있어도 카테고리 레벨에서 차단되어 있는 경우를 ⚠️ 경고로 표시 + +### 3.3. 역할 계층 구조 (Role Hierarchy) **[핵심]** +Discord의 역할 관리 제약 사항: +- 봇은 **자신보다 높은 역할**을 멤버에게 부여하거나 제거할 수 없음 +- 봇은 **자신보다 높은 역할**을 가진 멤버를 이동하거나 채널에서 추방할 수 없음 + +**진단 방법:** +1. 봇의 최상위 역할 위치(`botHighestRole.position`) 확인 +2. DB에 등록된 역할 자동 부여 대상 역할의 위치(`targetRole.position`) 확인 +3. `botHighestRole.position <= targetRole.position` 이면 ❌ 실패로 표시 + +--- + +## 4. UI 설계 (Embed 구성) + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔍 Kord 권한 진단 보고서 │ +├─────────────────────────────────────────────────────────┤ +│ ✅ 기본 기능 (공통) - 모든 권한 정상 │ +│ ✅ 임시 음성 채널 (전역) - 모든 권한 정상 │ +│ ⚠️ 임시 음성 채널 (카테고리) - Manage Roles 차단됨 │ +│ └ 📍 #음성-채널 카테고리: 채널 잠금/차단 기능 불가 │ +│ ❌ 초대 추적 - Manage Server 권한 없음 │ +│ └ 💡 초대자 추적 기능 전체 비활성화 │ +│ ❌ 역할 계층 (초대 역할 부여) - 봇 역할 위치 부족 │ +│ └ 💡 봇 역할을 'Member' 역할보다 위로 올려주세요. │ +├─────────────────────────────────────────────────────────┤ +│ 종합: ⚠️ 일부 기능 제한 / 2건 문제 감지 │ +└─────────────────────────────────────────────────────────┘ +``` + +| 아이콘 | 의미 | +|--------|------| +| ✅ | 권한 정상 보유 | +| ⚠️ | 전역 권한은 있으나 특정 채널/카테고리에서 오버라이드로 차단됨 | +| ❌ | 전역 권한 자체가 없음 / 역할 계층 문제 | + +--- + +## 5. 구현 설계 방향 (Technical Design) + +### 확장 가능한 플러그인 방식 + +새 기능이 추가될 때 코드 수정 없이 **권한 정의 항목만 추가**하면 되는 구조를 채택합니다. + +```typescript +// FeaturePermission 정의 단위 (예시) +interface FeaturePermission { + featureKey: string; // i18n 키 및 식별자 + scope: 'guild' | 'channel' | 'hierarchy'; + permissions?: PermissionFlags[]; + // hierarchy 타입인 경우 동적으로 DB에서 대상 역할 조회 +} + +// 배열에 항목만 추가하면 자동 진단 대상에 포함 +const FEATURE_PERMISSION_MAP: FeaturePermission[] = [ + { featureKey: 'BASIC', scope: 'guild', permissions: [...] }, + { featureKey: 'VOICE', scope: 'guild', permissions: [...] }, + { featureKey: 'INVITE_ROLE', scope: 'hierarchy' }, + // ↓ 향후 기능 추가 시 여기에 삽입 +]; +``` + +### 상태 판정 기준 + +| 케이스 | 판정 | +|--------|------| +| 전역 권한 없음 | ❌ FAIL | +| 전역 권한 있으나 채널/카테고리 오버라이드로 차단 | ⚠️ WARNING | +| 역할 계층이 부족하여 작동 불가 | ❌ FAIL | +| 모든 권한 정상 | ✅ SUCCESS | + +--- + +## 6. 구현 단계 (Phased Implementation) + +| 단계 | 내용 | +|------|------| +| **Phase 1** | 전역 권한 체크 + Embed UI 구현 | +| **Phase 2** | 카테고리/채널 오버라이드 체크 추가 | +| **Phase 3** | 역할 계층 구조 진단 로직 추가 | + +> [!NOTE] +> 이 기획서를 기준으로 `PermissionAuditService`를 재구현합니다. 이전에 작성된 임시 구현 코드는 이 기획서의 내용으로 교체됩니다. diff --git a/src/commands/auditPermissions.ts b/src/commands/auditPermissions.ts new file mode 100644 index 0000000..0a30173 --- /dev/null +++ b/src/commands/auditPermissions.ts @@ -0,0 +1,102 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChatInputCommandInteraction, + EmbedBuilder, + Colors, +} from 'discord.js'; +import { PermissionAuditService, AuditResult, AuditStatus } from '../services/PermissionAuditService'; +import { SupportedLocale, t } from '../i18n'; + +const STATUS_EMOJI: Record = { + SUCCESS: '✅', + WARNING: '⚠️', + FAIL: '❌', +}; + +function getOverallColor(results: AuditResult[]): number { + if (results.some((r) => r.status === 'FAIL')) return Colors.Red; + if (results.some((r) => r.status === 'WARNING')) return Colors.Yellow; + return Colors.Green; +} + +function buildResultLine(result: AuditResult, locale: SupportedLocale): string { + const emoji = STATUS_EMOJI[result.status]; + const featureName = + t(locale, `commands.permissionAudit.features.${result.featureKey}`) || result.featureKey; + + let line = `${emoji} **${featureName}**`; + + if (result.scope === 'channel' && result.channelId) { + line += ` (<#${result.channelId}>)`; + } + + if (result.missingPermissions.length > 0) { + line += `\n> \`${result.missingPermissions.join('`, `')}\``; + } + + if (result.scope === 'hierarchy' && result.hierarchyInfo) { + const { targetRoleName, botRolePosition, targetRolePosition } = result.hierarchyInfo; + if (result.status === 'FAIL') { + line += `\n> ${t(locale, 'commands.permissionAudit.hierarchyWarning', { + role: targetRoleName, + botPos: String(botRolePosition), + targetPos: String(targetRolePosition), + })}`; + } + } + + return line; +} + +export default { + data: new SlashCommandBuilder() + .setName('audit-permissions') + .setDescription('Check if the bot has all required permissions for its features.') + .setDescriptionLocalizations({ + ko: '봇이 원활하게 작동하기 위해 필요한 권한들을 진단합니다.', + }) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + if (!interaction.guild) return; + await interaction.deferReply({ ephemeral: true }); + + const results = await PermissionAuditService.auditGuild(interaction.guild); + + if (results.length === 0) { + await interaction.editReply({ content: t(locale, 'commands.permissionAudit.noResults') }); + return; + } + + // 결과를 ❌ FAIL → ⚠️ WARNING → ✅ SUCCESS 순으로 정렬 + const sorted = [...results].sort((a, b) => { + const order: Record = { FAIL: 0, WARNING: 1, SUCCESS: 2 }; + return order[a.status] - order[b.status]; + }); + + const lines = sorted.map((r) => buildResultLine(r, locale)); + + const failCount = results.filter((r) => r.status === 'FAIL').length; + const warnCount = results.filter((r) => r.status === 'WARNING').length; + + let summary: string; + if (failCount === 0 && warnCount === 0) { + summary = t(locale, 'commands.permissionAudit.summaryOk'); + } else { + summary = t(locale, 'commands.permissionAudit.summaryIssue', { + fail: String(failCount), + warn: String(warnCount), + }); + } + + const embed = new EmbedBuilder() + .setTitle(t(locale, 'commands.permissionAudit.title')) + .setDescription(lines.join('\n\n')) + .addFields({ name: t(locale, 'commands.permissionAudit.summaryLabel'), value: summary }) + .setColor(getOverallColor(results)) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3b89717..f4237af 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -133,6 +133,24 @@ export const en: TranslationSchema = { serverSet: 'Server language has been set to **{{locale}}**.', serverPermissionDenied: 'Only server administrators can change the server language.', }, + permissionAudit: { + title: 'Bot Permission Audit Report', + channel: 'Channel', + noResults: 'No features to audit. The bot may not be configured yet.', + summaryLabel: 'Summary', + summaryOk: '✅ All checks passed. No issues found.', + summaryIssue: '❌ {{fail}} failure(s) · ⚠️ {{warn}} warning(s) detected.', + hierarchyWarning: "Bot role (pos: {{botPos}}) must be above '{{role}}' (pos: {{targetPos}}) to manage it.", + features: { + BASIC: 'Basic Bot Functionality', + VOICE_GLOBAL: 'Voice Channels (Global)', + VOICE_GENERATOR_CHANNEL: 'Voice Generator Channel', + VOICE_GENERATOR_CATEGORY: 'Voice Generator Category', + INVITE_TRACKING: 'Invite Tracking', + INVITE_ROLE_HIERARCHY: 'Invite Role Assignment (Hierarchy)', + MIMIC_WEBHOOK: 'Message Mimic (Webhook)', + }, + }, }, // ── Modals ────────────────────────────────────────────── diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index d9d3c78..d0baca0 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -133,6 +133,24 @@ export const ko: TranslationSchema = { serverSet: '서버 언어가 **{{locale}}**(으)로 설정되었습니다.', serverPermissionDenied: '서버 언어 변경은 서버 관리자만 가능합니다.', }, + permissionAudit: { + title: '봇 권한 진단 보고서', + channel: '채널', + noResults: '진단할 기능이 없습니다. 봇이 아직 설정되지 않았을 수 있습니다.', + summaryLabel: '진단 결과 요약', + summaryOk: '✅ 모든 항목 정상. 문제가 없습니다.', + summaryIssue: '❌ {{fail}}개 실패 · ⚠️ {{warn}}개 경고 감지됨.', + hierarchyWarning: "봇 역할(순위: {{botPos}})이 '{{role}}'(순위: {{targetPos}})보다 위에 있어야 관리할 수 있습니다.", + features: { + BASIC: '기본 봇 기능', + VOICE_GLOBAL: '임시 음성 채널 (전역)', + VOICE_GENERATOR_CHANNEL: '음성 생성기 채널', + VOICE_GENERATOR_CATEGORY: '음성 생성기 카테고리', + INVITE_TRACKING: '초대 추적', + INVITE_ROLE_HIERARCHY: '초대 역할 부여 (계층 검사)', + MIMIC_WEBHOOK: '메시지 흉내 (Webhook)', + }, + }, }, // ── 모달 ──────────────────────────────────────────────── diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 7b8f748..87c6867 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -87,6 +87,24 @@ export interface TranslationSchema { serverSet: string; serverPermissionDenied: string; }; + permissionAudit: { + title: string; + channel: string; + noResults: string; + summaryLabel: string; + summaryOk: string; + summaryIssue: string; + hierarchyWarning: string; + features: { + BASIC: string; + VOICE_GLOBAL: string; + VOICE_GENERATOR_CHANNEL: string; + VOICE_GENERATOR_CATEGORY: string; + INVITE_TRACKING: string; + INVITE_ROLE_HIERARCHY: string; + MIMIC_WEBHOOK: string; + }; + }; }; // ── Modals ── diff --git a/src/services/PermissionAuditService.ts b/src/services/PermissionAuditService.ts new file mode 100644 index 0000000..6cb4834 --- /dev/null +++ b/src/services/PermissionAuditService.ts @@ -0,0 +1,243 @@ +import { + Guild, + PermissionFlagsBits, + PermissionsBitField, + GuildBasedChannel, + Role, +} from 'discord.js'; +import { prisma } from '../database'; +import { logger } from '../utils/logger'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type AuditStatus = 'SUCCESS' | 'WARNING' | 'FAIL'; + +export interface AuditResult { + featureKey: string; + status: AuditStatus; + missingPermissions: string[]; + scope: 'guild' | 'channel' | 'hierarchy'; + channelId?: string; + /** 역할 계층 관련 추가 정보 */ + hierarchyInfo?: { + botRolePosition: number; + targetRoleId: string; + targetRolePosition: number; + targetRoleName: string; + }; +} + +// ─── Feature Definitions (Plugin-style) ─────────────────────────────────────── +// 새 기능이 추가될 때 이 배열에 항목을 추가하기만 하면 자동으로 진단 대상에 포함됩니다. + +interface GuildFeature { + featureKey: string; + scope: 'guild'; + permissions: bigint[]; +} + +interface ChannelFeature { + featureKey: string; + scope: 'channel'; + permissions: bigint[]; + /** DB에서 채널 ID 목록을 가져오는 함수 */ + resolveChannelIds: (guildId: string) => Promise; +} + +interface HierarchyFeature { + featureKey: string; + scope: 'hierarchy'; + /** DB에서 점검이 필요한 역할 ID 목록을 가져오는 함수 */ + resolveTargetRoleIds: (guildId: string) => Promise; +} + +type FeatureDefinition = GuildFeature | ChannelFeature | HierarchyFeature; + +const FEATURE_DEFINITIONS: FeatureDefinition[] = [ + // ── 1. 공통 (Basic) ── + { + featureKey: 'BASIC', + scope: 'guild', + permissions: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + ], + }, + + // ── 2. 임시 음성 채널 (전역) ── + { + featureKey: 'VOICE_GLOBAL', + scope: 'guild', + permissions: [ + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.Connect, + ], + }, + + // ── 3. 임시 음성 채널 (생성기 채널 레벨) ── + { + featureKey: 'VOICE_GENERATOR_CHANNEL', + scope: 'channel', + permissions: [ + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageRoles, + PermissionFlagsBits.Connect, + ], + resolveChannelIds: async (guildId) => { + const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); + return generators.map((g) => g.channelId); + }, + }, + + // ── 4. 임시 음성 채널 (카테고리 레벨) ── + { + featureKey: 'VOICE_GENERATOR_CATEGORY', + scope: 'channel', + permissions: [ + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageRoles, + ], + resolveChannelIds: async (guildId) => { + const generators = await prisma.voiceGenerator.findMany({ where: { guildId } }); + return generators.map((g) => g.categoryId).filter((id): id is string => id !== null); + }, + }, + + // ── 5. 초대 추적 ── + { + featureKey: 'INVITE_TRACKING', + scope: 'guild', + permissions: [PermissionFlagsBits.ManageGuild], + }, + + // ── 6. 역할 자동 부여 (초대 연동) - 계층 검사 ── + { + featureKey: 'INVITE_ROLE_HIERARCHY', + scope: 'hierarchy', + resolveTargetRoleIds: async (guildId) => { + const inviteRoles = await prisma.inviteRole.findMany({ where: { guildId } }); + return inviteRoles.map((ir) => ir.roleId); + }, + }, + + // ── 7. 메시지 흉내 (Mimic) ── + { + featureKey: 'MIMIC_WEBHOOK', + scope: 'guild', + permissions: [ + PermissionFlagsBits.ManageWebhooks, + PermissionFlagsBits.ManageMessages, + ], + }, + + // ↓ 향후 기능 추가 시 여기에 항목을 추가하세요 ↓ + // e.g., Audit Log Channel, Setup Wizard 등 +]; + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class PermissionAuditService { + public static async auditGuild(guild: Guild): Promise { + const botMember = guild.members.me; + if (!botMember) return []; + + const results: AuditResult[] = []; + + for (const feature of FEATURE_DEFINITIONS) { + try { + if (feature.scope === 'guild') { + results.push(this.checkGuildPermissions(botMember.permissions, feature)); + } else if (feature.scope === 'channel') { + const channelIds = await feature.resolveChannelIds(guild.id); + for (const channelId of channelIds) { + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (channel) { + results.push(this.checkChannelPermissions(channel, botMember, feature)); + } + } + } else if (feature.scope === 'hierarchy') { + const roleIds = await feature.resolveTargetRoleIds(guild.id); + for (const roleId of roleIds) { + const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null); + if (role) { + results.push(this.checkHierarchy(guild, botMember.roles.highest, role, feature.featureKey)); + } + } + } + } catch (err) { + logger.error(`PermissionAuditService: Error checking feature "${feature.featureKey}"`, err); + } + } + + return results; + } + + // ── Private Helpers ──────────────────────────────────────────────────────── + + private static checkGuildPermissions( + currentPerms: Readonly, + feature: GuildFeature, + ): AuditResult { + const missing = this.getMissing(currentPerms, feature.permissions); + return { + featureKey: feature.featureKey, + status: missing.length > 0 ? 'FAIL' : 'SUCCESS', + missingPermissions: missing, + scope: 'guild', + }; + } + + private static checkChannelPermissions( + channel: GuildBasedChannel, + botMember: NonNullable, + feature: ChannelFeature, + ): AuditResult { + const channelPerms = channel.permissionsFor(botMember); + const missing = this.getMissing(channelPerms, feature.permissions); + return { + featureKey: feature.featureKey, + status: missing.length > 0 ? 'WARNING' : 'SUCCESS', + missingPermissions: missing, + scope: 'channel', + channelId: channel.id, + }; + } + + private static checkHierarchy( + guild: Guild, + botHighestRole: Role, + targetRole: Role, + featureKey: string, + ): AuditResult { + const isOk = botHighestRole.position > targetRole.position; + return { + featureKey, + status: isOk ? 'SUCCESS' : 'FAIL', + missingPermissions: [], + scope: 'hierarchy', + hierarchyInfo: { + botRolePosition: botHighestRole.position, + targetRoleId: targetRole.id, + targetRolePosition: targetRole.position, + targetRoleName: targetRole.name, + }, + }; + } + + private static getMissing(perms: Readonly | null, required: bigint[]): string[] { + if (!perms) return required.map(this.permToString); + return required.filter((p) => !perms.has(p)).map(this.permToString); + } + + private static permToString(perm: bigint): string { + return ( + Object.keys(PermissionFlagsBits).find( + (key) => (PermissionFlagsBits as Record)[key] === perm, + ) ?? perm.toString() + ); + } +}