package security import ( "context" "fmt" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) // The configuration calls below do their own permission checks, so every // way in (slash commands, buttons, anything added later) gets the same rules: // // - switching a system on: Manage Server, or owner/trusted // - switching a system off, alert and quarantine settings: owner/trusted // - the trusted list: owner only // // actorPerms is the actor's permission set as Discord sent it with the // interaction. const maxTrusted = 25 func hasManageGuild(perms int64) bool { return perms&(discordgo.PermissionAdministrator|discordgo.PermissionManageGuild) != 0 } // SetFeature turns the Join Gate, raid mode or anti-nuke on or off. func (e *Engine) SetFeature(ctx context.Context, guildID string, actor *discordgo.Member, actorPerms int64, feature string, on bool) error { if actor == nil || actor.User == nil { return ErrNotTrusted } switch feature { case securityrepo.FeatureJoinGate, securityrepo.FeatureAntiRaid, securityrepo.FeatureAntiNuke, securityrepo.FeatureHeat: default: return UserError("Unknown feature.") } trustedErr := e.RequireTrusted(ctx, guildID, actor) if !on && trustedErr != nil { return trustedErr } if on && trustedErr != nil && !hasManageGuild(actorPerms) { return UserError("You need Manage Server to turn security features on.") } if err := e.st.SetEnabled(ctx, parseID(guildID), feature, on); err != nil { return err } e.Invalidate(guildID) state := "off" if on { state = "on" } e.event(ctx, guildID, "settings", actor.User.ID, "", feature+" "+state) return nil } func (e *Engine) roleByID(guildID, roleID string) (*discordgo.Role, []*discordgo.Role, error) { roles, err := e.d.GuildRoles(guildID) if err != nil { return nil, nil, fmt.Errorf("listing roles: %w", err) } for _, r := range roles { if r.ID == roleID { return r, roles, nil } } return nil, roles, UserError("That role isn't in this server.") } // SetAlerts sets where alerts go and which role they ping. Changing it could // hide alerts from the staff, so it needs owner or trusted. func (e *Engine) SetAlerts(ctx context.Context, guildID string, actor *discordgo.Member, channelID, roleID string) error { if err := e.RequireTrusted(ctx, guildID, actor); err != nil { return err } ch, err := e.d.Channel(channelID) if err != nil || ch.GuildID != guildID { return UserError("That channel isn't in this server.") } if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews { return UserError("Alerts need a text channel.") } if roleID != "" { if roleID == guildID { return UserError("Alerts can't ping @everyone. Pick a staff role.") } if _, _, err := e.roleByID(guildID, roleID); err != nil { return err } } if err := e.st.SetAlerts(ctx, parseID(guildID), parseID(channelID), parseID(roleID)); err != nil { return err } e.Invalidate(guildID) e.event(ctx, guildID, "settings", actor.User.ID, channelID, "alerts channel and role changed") return nil } // ValidateQuarantineRole checks a role can safely be the quarantine role: // in this server, not @everyone, not managed, no dangerous permissions, and // below Velox's highest role so the bot can assign it. func (e *Engine) ValidateQuarantineRole(guildID, roleID string) error { if roleID == guildID { return UserError("@everyone can't be the quarantine role.") } role, roles, err := e.roleByID(guildID, roleID) if err != nil { return err } if role.Managed { return UserError("That role is managed by an integration and can't be assigned.") } if role.Permissions&DangerousPerms != 0 { return UserError("The quarantine role can't have Administrator, Manage Server, Manage Roles or Manage Webhooks.") } me, err := e.d.GuildMember(guildID, e.botID) if err != nil { return fmt.Errorf("looking up Velox: %w", err) } top := -1 for _, r := range roles { for _, mine := range me.Roles { if r.ID == mine && r.Position > top { top = r.Position } } } if role.Position >= top { return UserError("That role is above Velox's highest role, so Velox couldn't assign it. Move it below Velox's role.") } return nil } // SetQuarantineRole sets (or with "" clears) the quarantine role. func (e *Engine) SetQuarantineRole(ctx context.Context, guildID string, actor *discordgo.Member, roleID string) error { if err := e.RequireTrusted(ctx, guildID, actor); err != nil { return err } if roleID != "" { if err := e.ValidateQuarantineRole(guildID, roleID); err != nil { return err } } if err := e.st.SetQuarantineRole(ctx, parseID(guildID), parseID(roleID)); err != nil { return err } e.Invalidate(guildID) e.event(ctx, guildID, "settings", actor.User.ID, roleID, "quarantine role changed") return nil } // Trust adds a user or role to the trusted list. Owner only: anyone trusted // can switch protection off, so trust must not spread on its own. func (e *Engine) Trust(ctx context.Context, guildID string, actor *discordgo.Member, kind, targetID string) error { if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) { return ErrNotOwner } _, trusted, err := e.load(ctx, guildID) if err != nil { return err } if len(trusted) >= maxTrusted { return UserError(fmt.Sprintf("The trusted list can hold at most %d entries.", maxTrusted)) } switch kind { case "role": if targetID == guildID { return UserError("Trusting @everyone would make everyone trusted.") } role, _, err := e.roleByID(guildID, targetID) if err != nil { return err } if role.Managed { return UserError("Integration roles can't be trusted; trust the bot user instead.") } case "user": if _, err := e.d.GuildMember(guildID, targetID); err != nil { return UserError("That user isn't in this server.") } default: return UserError("Trust a user or a role.") } if err := e.st.AddTrusted(ctx, parseID(guildID), securityrepo.Trusted{TargetID: parseID(targetID), Kind: kind, AddedBy: parseID(actor.User.ID)}); err != nil { return err } e.Invalidate(guildID) e.event(ctx, guildID, "trusted_added", actor.User.ID, targetID, kind) return nil } // Untrust removes a user or role from the trusted list. Owner only. func (e *Engine) Untrust(ctx context.Context, guildID string, actor *discordgo.Member, targetID string) error { if actor == nil || actor.User == nil || !e.IsOwner(guildID, actor.User.ID) { return ErrNotOwner } ok, err := e.st.RemoveTrusted(ctx, parseID(guildID), parseID(targetID)) if err != nil { return err } if !ok { return UserError("That isn't on the trusted list.") } e.Invalidate(guildID) e.event(ctx, guildID, "trusted_removed", actor.User.ID, targetID, "") return nil }