Browse Source

backend functionality for creating/viewing slack integrations

Alexander Belanger 4 years ago
parent
commit
d0f6fb5646

+ 2 - 0
.gitignore

@@ -11,6 +11,8 @@ internal/local_templates
 gon*.hcl
 *prod.Dockerfile
 staging.sh
+*.crt
+*.key
 
 # Local .terraform directories
 **/.terraform/*

+ 1 - 0
cmd/app/main.go

@@ -74,6 +74,7 @@ func main() {
 		&ints.HelmRepoTokenCache{},
 		&ints.GithubAppInstallation{},
 		&ints.GithubAppOAuthIntegration{},
+		&ints.SlackIntegration{},
 	)
 
 	if err != nil {

+ 1 - 0
cmd/migrate/main.go

@@ -59,6 +59,7 @@ func main() {
 		&ints.HelmRepoTokenCache{},
 		&ints.GithubAppInstallation{},
 		&ints.GithubAppOAuthIntegration{},
+		&ints.SlackIntegration{},
 	)
 
 	if err != nil {

+ 72 - 0
docker-compose.dev-secure.yaml

@@ -0,0 +1,72 @@
+version: "3"
+services:
+  webpack:
+    build:
+      context: ./dashboard
+      dockerfile: ./docker/dev.Dockerfile
+    env_file:
+      - ./dashboard/.env
+    restart: on-failure
+    volumes:
+      - ./dashboard/src:/webpack/src:rw,cached
+      - ./dashboard/package.json:/webpack/package.json
+  porter:
+    build:
+      context: .
+      dockerfile: ./docker/dev.Dockerfile
+    depends_on:
+      - postgres
+    env_file:
+      - ./docker/.env
+    command: /bin/sh -c '/porter/bin/migrate; air -c .air.toml;'
+    restart: on-failure
+    volumes:
+      - ./cmd:/porter/cmd
+      - ./internal:/porter/internal
+      - ./server:/porter/server
+      - ./api:/porter/api
+      - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
+      - ./docker/github_app_private_key.pem:/porter/docker/github_app_private_key.pem
+  postgres:
+    image: postgres:latest
+    container_name: postgres
+    environment:
+      - POSTGRES_USER=porter
+      - POSTGRES_PASSWORD=porter
+      - POSTGRES_DB=porter
+    ports:
+      - 5400:5432
+    volumes:
+      - database:/var/lib/postgresql/data
+  redis:
+    image: redis:latest
+    container_name: redis
+    ports:
+      - 6379:6379
+    volumes:
+      - database:/var/lib/postgresql/data
+  chartmuseum:
+    image: docker.io/bitnami/chartmuseum:0-debian-10
+    container_name: chartmuseum
+    ports:
+      - 5000:8080
+    volumes:
+      - chartmuseum:/bitnami/data
+  nginx:
+    image: nginx:mainline-alpine
+    container_name: nginx
+    restart: unless-stopped
+    ports:
+      - 443:443
+    volumes:
+      - ./docker/localhost.crt:/etc/ssl/localhost.crt
+      - ./docker/localhost.key:/etc/ssl/localhost.key
+      - ./docker/nginx_local_secure.conf:/etc/nginx/nginx.conf:ro
+    depends_on:
+      - porter
+      - webpack
+
+volumes:
+  database:
+  metabase:
+  chartmuseum:

+ 46 - 0
docker/nginx_local_secure.conf

@@ -0,0 +1,46 @@
+events {}
+http {
+    upstream api {
+        server porter:8080;
+    }
+
+    upstream webpack {
+        server webpack:8080;
+    }
+
+    server {
+        listen               443 ssl;
+        ssl_certificate      /etc/ssl/localhost.crt;
+        ssl_certificate_key  /etc/ssl/localhost.key;
+        ssl_ciphers          HIGH:!aNULL:!MD5;
+
+        server_name localhost;
+
+        location /api/ {
+            proxy_pass http://api;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+
+        location / {
+            proxy_pass http://webpack;
+            proxy_pass_header Content-Security-Policy;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+    }
+
+    client_max_body_size 10M;
+}

+ 25 - 0
docs/developing/setup.md

@@ -53,3 +53,28 @@ Once WSL is installed, head to docker and enable WSL Integration.
 ![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
 
 Next, continue with the Getting Started Section
+
+## Secure Localhost Setup
+
+Sometimes, it may be necessary to serve securely over `https://localhost` (for example, required by Slack integrations). Run the following command from the repository root:
+
+```sh
+openssl req -x509 -out ./docker/localhost.crt -keyout ./docker/localhost.key \
+  -newkey rsa:2048 -nodes -sha256 \
+  -subj '/CN=localhost' -extensions EXT -config <( \
+   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
+```
+
+Update `./docker/.env` with the following:
+
+```
+SERVER_URL=https://localhost
+```
+
+If using Chrome, paste the following into the Chrome address bar:
+
+> chrome://flags/#allow-insecure-localhost
+
+And then Enable the **Allow invalid certificates for resources loaded from localhost** field. 
+
+Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 

+ 3 - 0
internal/config/config.go

@@ -59,6 +59,9 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
 
+	SlackClientID     string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
+
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`

+ 78 - 0
internal/integrations/slack/slack.go

@@ -0,0 +1,78 @@
+package slack
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"golang.org/x/oauth2"
+)
+
+func TokenToSlackIntegration(token *oauth2.Token) (*integrations.SlackIntegration, error) {
+	// cast the "incoming_webhook" field to a map[string]string
+	webhookConfig, ok := token.Extra("incoming_webhook").(map[string]interface{})
+
+	if !ok {
+		return nil, fmt.Errorf("could not get incoming webhook field from token")
+	}
+
+	teamInfo, err := getTeamInfo(token)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &integrations.SlackIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken: []byte(token.AccessToken),
+		},
+		TeamID:           teamInfo.Team.ID,
+		TeamIconURL:      teamInfo.Team.Icon.Image132,
+		Channel:          webhookConfig["channel"].(string),
+		ChannelID:        webhookConfig["channel_id"].(string),
+		ConfigurationURL: webhookConfig["configuration_url"].(string),
+		Webhook:          []byte(webhookConfig["url"].(string)),
+	}, nil
+}
+
+type teamInfoResponse struct {
+	OK   bool `json:"ok"`
+	Team struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+		Icon struct {
+			Image132 string `json:"image_132"`
+		}
+	} `json:"team"`
+}
+
+func getTeamInfo(token *oauth2.Token) (*teamInfoResponse, error) {
+	url := "https://slack.com/api/team.info"
+
+	// Create a new request using http
+	req, err := http.NewRequest("GET", url, nil)
+
+	// add authorization header to the request
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
+
+	// Send req using http Client
+	client := &http.Client{}
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+
+	teamInfo := teamInfoResponse{}
+
+	err = json.NewDecoder(resp.Body).Decode(&teamInfo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &teamInfo, nil
+}

+ 69 - 0
internal/models/integrations/slack.go

@@ -0,0 +1,69 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// SlackIntegration is a webhook notifier to a specific channel in a Slack workspace.
+type SlackIntegration struct {
+	gorm.Model
+	SharedOAuthModel
+
+	// The name of the auth mechanism
+	Client OAuthIntegrationClient `json:"client"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The ID for the Slack team
+	TeamID string
+
+	// The icon url for the Slack team
+	TeamIconURL string
+
+	// The channel name that the Slack app is installed in
+	Channel string
+
+	// The channel id that the Slack app is installed in
+	ChannelID string
+
+	// The URL for configuring the workspace app instance
+	ConfigurationURL string
+
+	// ------------------------------------------------------------------
+	// All fields below encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The webhook to call
+	Webhook []byte
+}
+
+// SlackIntegrationExternal is an external SlackIntegration to be shared over
+// rest
+type SlackIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+
+	// The ID for the Slack team
+	TeamID string `json:"team_id"`
+
+	// The icon url for the Slack team
+	TeamIconURL string `json:"team_icon_url"`
+
+	// The channel name that the Slack app is installed in
+	Channel string `json:"channel"`
+}
+
+// Externalize generates an external SlackIntegration to be shared over
+// rest
+func (s *SlackIntegration) Externalize() *SlackIntegrationExternal {
+	return &SlackIntegrationExternal{
+		ID:          s.ID,
+		ProjectID:   s.ProjectID,
+		TeamID:      s.TeamID,
+		TeamIconURL: s.TeamIconURL,
+		Channel:     s.Channel,
+	}
+}

+ 15 - 1
internal/oauth/config.go

@@ -4,9 +4,10 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"time"
+
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
-	"time"
 
 	"golang.org/x/oauth2"
 )
@@ -85,6 +86,19 @@ func NewGoogleClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewSlackClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://slack.com/oauth/v2/authorize",
+			TokenURL: "https://slack.com/api/oauth.v2.access",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/slack/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)

+ 1 - 0
internal/repository/gorm/repository.go

@@ -31,5 +31,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		AWSIntegration:            NewAWSIntegrationRepository(db, key),
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
+		SlackIntegration:          NewSlackIntegrationRepository(db, key),
 	}
 }

+ 157 - 0
internal/repository/gorm/slack.go

@@ -0,0 +1,157 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// SlackIntegrationRepository uses gorm.DB for querying the database
+type SlackIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewSlackIntegrationRepository returns a SlackIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewSlackIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.SlackIntegrationRepository {
+	return &SlackIntegrationRepository{db, key}
+}
+
+// CreateKubeIntegration creates a new kube auth mechanism
+func (repo *SlackIntegrationRepository) CreateSlackIntegration(
+	slackInt *ints.SlackIntegration,
+) (*ints.SlackIntegration, error) {
+	err := repo.EncryptSlackIntegrationData(slackInt, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(slackInt).Error; err != nil {
+		return nil, err
+	}
+
+	return slackInt, nil
+}
+
+// ListSlackIntegrationsByProjectID finds all kube auth mechanisms
+// for a given project id
+func (repo *SlackIntegrationRepository) ListSlackIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.SlackIntegration, error) {
+	slackInts := []*ints.SlackIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&slackInts).Error; err != nil {
+		return nil, err
+	}
+
+	for _, slackInt := range slackInts {
+		repo.DecryptSlackIntegrationData(slackInt, repo.key)
+	}
+
+	return slackInts, nil
+}
+
+// EncryptSlackIntegrationData will encrypt the slack integration data before
+// writing to the DB
+func (repo *SlackIntegrationRepository) EncryptSlackIntegrationData(
+	slackInt *ints.SlackIntegration,
+	key *[32]byte,
+) error {
+	if len(slackInt.ClientID) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.ClientID = cipherData
+	}
+
+	if len(slackInt.AccessToken) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.AccessToken = cipherData
+	}
+
+	if len(slackInt.RefreshToken) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.RefreshToken = cipherData
+	}
+
+	if len(slackInt.Webhook) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.Webhook, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.Webhook = cipherData
+	}
+
+	return nil
+}
+
+// DecryptSlackIntegrationData will decrypt the slack integration data before
+// returning it from the DB
+func (repo *SlackIntegrationRepository) DecryptSlackIntegrationData(
+	slackInt *ints.SlackIntegration,
+	key *[32]byte,
+) error {
+	if len(slackInt.ClientID) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.ClientID = plaintext
+	}
+
+	if len(slackInt.AccessToken) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.AccessToken = plaintext
+	}
+
+	if len(slackInt.RefreshToken) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.RefreshToken = plaintext
+	}
+
+	if len(slackInt.Webhook) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.Webhook, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.Webhook = plaintext
+	}
+
+	return nil
+}

+ 6 - 0
internal/repository/integrations.go

@@ -45,6 +45,12 @@ type GithubAppOAuthIntegrationRepository interface {
 	UpdateGithubAppOauthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
 }
 
+// SlackIntegrationRepository represents the set of queries on a Slack integration
+type SlackIntegrationRepository interface {
+	CreateSlackIntegration(slackInt *ints.SlackIntegration) (*ints.SlackIntegration, error)
+	ListSlackIntegrationsByProjectID(projectID uint) ([]*ints.SlackIntegration, error)
+}
+
 // AWSIntegrationRepository represents the set of queries on the AWS auth
 // mechanism
 type AWSIntegrationRepository interface {

+ 1 - 0
internal/repository/repository.go

@@ -24,4 +24,5 @@ type Repository struct {
 	AWSIntegration            AWSIntegrationRepository
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
+	SlackIntegration          SlackIntegrationRepository
 }

+ 23 - 7
server/api/api.go

@@ -87,6 +87,7 @@ type App struct {
 	GithubAppConf     *oauth.GithubAppConf
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
+	SlackConf         *oauth2.Config
 
 	db              *gorm.DB
 	validator       *vr.Validate
@@ -96,13 +97,14 @@ type App struct {
 }
 
 type AppCapabilities struct {
-	Provisioning bool `json:"provisioner"`
-	Github       bool `json:"github"`
-	BasicLogin   bool `json:"basic_login"`
-	GithubLogin  bool `json:"github_login"`
-	GoogleLogin  bool `json:"google_login"`
-	Email        bool `json:"email"`
-	Analytics    bool `json:"analytics"`
+	Provisioning       bool `json:"provisioner"`
+	Github             bool `json:"github"`
+	BasicLogin         bool `json:"basic_login"`
+	GithubLogin        bool `json:"github_login"`
+	GoogleLogin        bool `json:"google_login"`
+	SlackNotifications bool `json:"slack_notifs"`
+	Email              bool `json:"email"`
+	Analytics          bool `json:"analytics"`
 }
 
 // New returns a new App instance
@@ -202,6 +204,20 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
+	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {
+		app.Capabilities.SlackNotifications = true
+
+		app.SlackConf = oauth.NewSlackClient(&oauth.Config{
+			ClientID:     sc.SlackClientID,
+			ClientSecret: sc.SlackClientSecret,
+			Scopes: []string{
+				"incoming-webhook",
+				"team:read",
+			},
+			BaseURL: sc.ServerURL,
+		})
+	}
+
 	if sc.DOClientID != "" && sc.DOClientSecret != "" {
 		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
 			ClientID:     sc.DOClientID,

+ 130 - 0
server/api/oauth_slack_handler.go

@@ -0,0 +1,130 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+)
+
+// HandleSlackOAuthStartProject starts the oauth2 flow for a project slack request.
+// In this handler, the project id gets written to the session (along with the oauth
+// state param), so that the correct project id can be identified in the callback.
+func (app *App) HandleSlackOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, true)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.SlackConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleSlackOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+func (app *App) HandleSlackOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.SlackConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		fmt.Println("ERR IS", err)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	slackInt, err := slack.TokenToSlackIntegration(token)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	slackInt.UserID = userID
+	slackInt.ProjectID = projID
+
+	// save to repository
+	slackInt, err = app.Repo.SlackIntegration.CreateSlackIntegration(slackInt)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}
+
+// HandleListSlackIntegrations lists all slack integrations belonging to a certain project
+// ID
+func (app *App) HandleListSlackIntegrations(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	slackInts, err := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extSlackInts := make([]*integrations.SlackIntegrationExternal, 0)
+
+	for _, slackInt := range slackInts {
+		extSlackInts = append(extSlackInts, slackInt.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extSlackInts); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 27 - 0
server/router/router.go

@@ -288,6 +288,22 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleDOOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/projects/{project_id}/slack",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleSlackOAuthStartProject, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/oauth/slack/callback",
+				requestlog.NewHandler(a.HandleSlackOAuthCallback, l),
+			)
+
 			// /api/projects routes
 			r.Method(
 				"GET",
@@ -841,6 +857,17 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			// /api/projects/{project_id}/slack_integrations routes
+			r.Method(
+				"GET",
+				"/projects/{project_id}/slack_integrations",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleListSlackIntegrations, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/helmrepos routes
 			r.Method(
 				"POST",