From b511f2bc23567f46f66cbecfae0a73ac91bd00b0 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 20 Oct 2025 07:39:24 +0200 Subject: [PATCH] disallow personal accounts by default, add refresh --- cmd/server/main.go | 19 ++++++++----- internal/config/config.go | 11 ++++---- internal/github/github.go | 26 ++++++++++++------ internal/slack/home_handler.go | 3 +++ internal/slack/slack.go | 49 +++++++++++++++++++++++++++++++++- pkg/home/ui.go | 29 ++++++++++++++++++++ 6 files changed, 116 insertions(+), 21 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 78b892b..ad1c0a6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -148,7 +148,7 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi configManager := config.New(ctx) // Initialize GitHub installation manager. - githubManager, err := github.NewManager(ctx, cfg.GitHubAppID, cfg.GitHubPrivateKey) + githubManager, err := github.NewManager(ctx, cfg.GitHubAppID, cfg.GitHubPrivateKey, cfg.AllowPersonalAccounts) if err != nil { slog.Error("failed to initialize GitHub installation manager", "error", err) cancel() // Ensure cleanup happens before exit @@ -856,18 +856,23 @@ func loadConfig() (*config.ServerConfig, error) { slog.Info("loading configuration values") + // Parse personal accounts flag (default: false for DoS protection) + allowPersonalAccounts := os.Getenv("ALLOW_PERSONAL_ACCOUNTS") == "true" + cfg := &config.ServerConfig{ - DataDir: dataDir, - SlackSigningSecret: getSecretValue("SLACK_SIGNING_SECRET"), - GitHubAppID: os.Getenv("GITHUB_APP_ID"), // Not a secret, just config - GitHubPrivateKey: githubPrivateKey, - SprinklerURL: sprinklerURL, + DataDir: dataDir, + SlackSigningSecret: getSecretValue("SLACK_SIGNING_SECRET"), + GitHubAppID: os.Getenv("GITHUB_APP_ID"), // Not a secret, just config + GitHubPrivateKey: githubPrivateKey, + SprinklerURL: sprinklerURL, + AllowPersonalAccounts: allowPersonalAccounts, } slog.Info("configuration loaded", "has_slack_signing_secret", cfg.SlackSigningSecret != "", "has_github_app_id", cfg.GitHubAppID != "", - "has_github_private_key", cfg.GitHubPrivateKey != "") + "has_github_private_key", cfg.GitHubPrivateKey != "", + "allow_personal_accounts", cfg.AllowPersonalAccounts) // Validate required fields if cfg.SlackSigningSecret == "" { diff --git a/internal/config/config.go b/internal/config/config.go index 82c5d28..577ea73 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,11 +28,12 @@ const ( // ServerConfig holds the server configuration from environment variables. type ServerConfig struct { - DataDir string - SlackSigningSecret string - GitHubAppID string - GitHubPrivateKey string - SprinklerURL string + DataDir string + SlackSigningSecret string + GitHubAppID string + GitHubPrivateKey string + SprinklerURL string + AllowPersonalAccounts bool // Allow processing GitHub personal accounts (default: false for DoS protection) } // RepoConfig represents the slack.yaml configuration for a GitHub org. diff --git a/internal/github/github.go b/internal/github/github.go index 3b11f01..0ae5fd5 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -635,14 +635,15 @@ func (c *Client) InstallationToken(ctx context.Context) string { // Manager manages multiple GitHub App installations. type Manager struct { - privateKey *rsa.PrivateKey - clients map[string]*Client // org -> client - appID string - mu sync.RWMutex + privateKey *rsa.PrivateKey + clients map[string]*Client // org -> client + appID string + allowPersonalAccounts bool // Allow processing personal accounts (default: false for DoS protection) + mu sync.RWMutex } // NewManager creates a new installation manager. -func NewManager(ctx context.Context, appID, privateKeyPEM string) (*Manager, error) { +func NewManager(ctx context.Context, appID, privateKeyPEM string, allowPersonalAccounts bool) (*Manager, error) { // Parse the private key. block, _ := pem.Decode([]byte(privateKeyPEM)) if block == nil { @@ -669,9 +670,10 @@ func NewManager(ctx context.Context, appID, privateKeyPEM string) (*Manager, err } m := &Manager{ - clients: make(map[string]*Client), - appID: appID, - privateKey: key, + clients: make(map[string]*Client), + appID: appID, + privateKey: key, + allowPersonalAccounts: allowPersonalAccounts, } // Discover installations at startup. @@ -754,6 +756,14 @@ func (m *Manager) RefreshInstallations(ctx context.Context) error { continue } + // Skip personal accounts if not explicitly allowed (DoS protection) + if !m.allowPersonalAccounts && inst.Account.GetType() == "User" { + slog.Debug("skipping personal account", + "account", inst.Account.GetLogin(), + "type", "User") + continue + } + org := inst.Account.GetLogin() // Create client for this installation. diff --git a/internal/slack/home_handler.go b/internal/slack/home_handler.go index a545295..65e44e8 100644 --- a/internal/slack/home_handler.go +++ b/internal/slack/home_handler.go @@ -95,6 +95,9 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser return h.publishPlaceholderHome(slackClient, slackUserID) } + // Add workspace orgs to dashboard for UI display + dashboard.WorkspaceOrgs = workspaceOrgs + // Build Block Kit UI - use first org as primary blocks := home.BuildBlocks(dashboard, workspaceOrgs[0]) diff --git a/internal/slack/slack.go b/internal/slack/slack.go index 84777eb..2bb6d58 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -799,6 +799,7 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) { c.homeViewHandlerMu.RUnlock() if handler != nil { + //nolint:contextcheck // Use detached context for async event processing - prevents webhook events from being lost during shutdown go func(teamID, userID string) { homeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -858,7 +859,8 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request switch interaction.Type { case slack.InteractionTypeBlockActions: // Handle block actions (buttons, selects, etc.). - slog.Debug("received block action", "interaction", interaction) + //nolint:contextcheck // handleBlockAction spawns async goroutines with detached contexts - this is intentional + c.handleBlockAction(&interaction) case slack.InteractionTypeViewSubmission: // Handle modal submissions. slog.Debug("received view submission", "interaction", interaction) @@ -870,6 +872,51 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request writer.WriteHeader(http.StatusOK) } +// handleBlockAction handles block action interactions (button clicks, etc.). +func (c *Client) handleBlockAction(interaction *slack.InteractionCallback) { + // Process each action in the callback + for _, action := range interaction.ActionCallback.BlockActions { + slog.Debug("processing block action", + "action_id", action.ActionID, + "user", interaction.User.ID, + "team", interaction.Team.ID) + + switch action.ActionID { + case "refresh_dashboard": + // Trigger home view refresh + c.homeViewHandlerMu.RLock() + handler := c.homeViewHandler + c.homeViewHandlerMu.RUnlock() + + if handler != nil { + // Refresh asynchronously to avoid blocking the response + //nolint:contextcheck // Use detached context for async button refresh - ensures operation completes even if parent context is cancelled + go func(teamID, userID string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + slog.Info("refreshing dashboard via button click", + "team_id", teamID, + "user", userID) + + if err := handler(ctx, teamID, userID); err != nil { + slog.Error("failed to refresh dashboard", + "team_id", teamID, + "user", userID, + "error", err) + } + }(interaction.Team.ID, interaction.User.ID) + } else { + slog.Warn("refresh requested but no home view handler registered", + "user", interaction.User.ID) + } + + default: + slog.Debug("unhandled action_id", "action_id", action.ActionID) + } + } +} + // SlashCommandHandler handles Slack slash commands. func (c *Client) SlashCommandHandler(writer http.ResponseWriter, r *http.Request) { // Verify the request signature. diff --git a/pkg/home/ui.go b/pkg/home/ui.go index da95462..f003e67 100644 --- a/pkg/home/ui.go +++ b/pkg/home/ui.go @@ -20,6 +20,35 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { ), ) + // Organization monitoring section - show which orgs this workspace tracks + if len(dashboard.WorkspaceOrgs) > 0 { + var orgLinks []string + for _, org := range dashboard.WorkspaceOrgs { + configURL := fmt.Sprintf("https://github.com/%s/.codeGROOVE/blob/main/slack.yaml", org) + orgLinks = append(orgLinks, fmt.Sprintf("<%s|%s>", configURL, org)) + } + orgsText := fmt.Sprintf("*Monitoring:* %s", strings.Join(orgLinks, " • ")) + + blocks = append(blocks, + slack.NewContextBlock( + "", + slack.NewTextBlockObject("mrkdwn", orgsText, false, false), + ), + ) + } + + // Refresh button + blocks = append(blocks, + slack.NewActionBlock( + "refresh_actions", + slack.NewButtonBlockElement( + "refresh_dashboard", + "refresh", + slack.NewTextBlockObject("plain_text", "🔄 Refresh", false, false), + ), + ), + ) + counts := dashboard.Counts() // Incoming section - matches dashboard's card-based sections