2
0
Эх сурвалжийг харах

Merge pull request #1015 from porter-dev/0.8.0-pod-events-backend

[0.8.0] Backend implementation of pod events
abelanger5 4 жил өмнө
parent
commit
f3928b471b

+ 40 - 0
internal/forms/events.go

@@ -0,0 +1,40 @@
+package forms
+
+import (
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateEventForm is the input for creating a new event
+type CreateEventForm struct {
+	ResourceType string    `json:"resource_type"`
+	Name         string    `json:"name"`
+	OwnerType    string    `json:"owner_type"`
+	OwnerName    string    `json:"owner_name"`
+	EventType    string    `json:"event_type"`
+	Namespace    string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Timestamp    time.Time `json:"timestamp"`
+	Data         []string  `json:"data"`
+}
+
+func (c *CreateEventForm) ToEvent(projID uint, clusterID uint) *models.Event {
+	return &models.Event{
+		ProjectID:    projID,
+		ClusterID:    clusterID,
+		OwnerType:    c.OwnerType,
+		OwnerName:    c.OwnerName,
+		EventType:    c.EventType,
+		RefType:      c.ResourceType,
+		RefName:      c.Name,
+		RefNamespace: c.Namespace,
+		Message:      c.Message,
+		Reason:       c.Reason,
+		Timestamp:    c.Timestamp,
+		Data:         []byte(strings.Join(c.Data, "\n")),
+		Expiry:       time.Now().Add(24 * 14 * time.Hour),
+	}
+}

+ 102 - 0
internal/models/events.go

@@ -0,0 +1,102 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Event model that refers to a type of event from a Kubernetes cluster
+type Event struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+
+	OwnerType string
+	OwnerName string
+
+	EventType    string
+	RefType      string
+	RefName      string
+	RefNamespace string
+	Message      string
+	Reason       string
+	Data         []byte
+
+	Timestamp time.Time
+	Expiry    time.Time
+}
+
+// EventExternal is an event to be shared over REST
+type EventExternal struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	OwnerType string `json:"owner_type"`
+	OwnerName string `json:"owner_name"`
+
+	EventType    string    `json:"event_type"`
+	RefType      string    `json:"resource_type"`
+	RefName      string    `json:"name"`
+	RefNamespace string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Data         []byte    `json:"data"`
+	Timestamp    time.Time `json:"timestamp"`
+}
+
+type EventExternalSimple struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	OwnerType string `json:"owner_type"`
+	OwnerName string `json:"owner_name"`
+
+	EventType    string    `json:"event_type"`
+	RefType      string    `json:"resource_type"`
+	RefName      string    `json:"name"`
+	RefNamespace string    `json:"namespace"`
+	Message      string    `json:"message"`
+	Reason       string    `json:"reason"`
+	Timestamp    time.Time `json:"timestamp"`
+}
+
+// Externalize generates an external Event to be shared over REST
+func (e *Event) Externalize() *EventExternal {
+	return &EventExternal{
+		ID:           e.ID,
+		ProjectID:    e.ProjectID,
+		ClusterID:    e.ClusterID,
+		OwnerName:    e.OwnerName,
+		OwnerType:    e.OwnerType,
+		EventType:    e.EventType,
+		RefType:      e.RefType,
+		RefName:      e.RefName,
+		RefNamespace: e.RefNamespace,
+		Reason:       e.Reason,
+		Timestamp:    e.Timestamp,
+		Data:         e.Data,
+	}
+}
+
+// Externalize generates an external Event to be shared over REST
+func (e *Event) ExternalizeSimple() *EventExternalSimple {
+	return &EventExternalSimple{
+		ID:           e.ID,
+		ProjectID:    e.ProjectID,
+		ClusterID:    e.ClusterID,
+		OwnerName:    e.OwnerName,
+		OwnerType:    e.OwnerType,
+		EventType:    e.EventType,
+		RefType:      e.RefType,
+		RefName:      e.RefName,
+		RefNamespace: e.RefNamespace,
+		Reason:       e.Reason,
+		Timestamp:    e.Timestamp,
+	}
+}

+ 31 - 0
internal/repository/event.go

@@ -0,0 +1,31 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ListEventOpts are the options for listing events
+type ListEventOpts struct {
+	ClusterID uint
+
+	Limit int    `schema:"limit"`
+	Skip  int    `schema:"skip"`
+	Type  string `schema:"type"`
+
+	// can only be "timestamp" for now
+	SortBy string `schema:"sort_by"`
+
+	OwnerType string `schema:"owner_type"`
+	OwnerName string `schema:"owner_name"`
+
+	// Decrypt is whether to decrypt the underlying Data field, which may not be desired
+	// for basic list operations
+	Decrypt bool
+}
+
+// EventRepository represents the set of queries on the
+// Event model
+type EventRepository interface {
+	CreateEvent(event *models.Event) (*models.Event, error)
+	ReadEvent(id uint, projID uint, clusterID uint) (*models.Event, error)
+	ListEventsByProjectID(projectID uint, opts *ListEventOpts) ([]*models.Event, error)
+	DeleteEvent(id uint) error
+}

+ 163 - 0
internal/repository/gorm/event.go

@@ -0,0 +1,163 @@
+package gorm
+
+import (
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// EventRepository uses gorm.DB for querying the database
+type EventRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewEventRepository returns an EventRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewEventRepository(db *gorm.DB, key *[32]byte) repository.EventRepository {
+	return &EventRepository{db, key}
+}
+
+// CreateEvent creates a new kube auth mechanism
+func (repo *EventRepository) CreateEvent(
+	event *models.Event,
+) (*models.Event, error) {
+	err := repo.EncryptEventData(event, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEvent finds an event by id
+func (repo *EventRepository) ReadEvent(
+	id, projID, clusterID uint,
+) (*models.Event, error) {
+	event := &models.Event{}
+
+	// preload Clusters association
+	if err := repo.db.Where(
+		"id = ? AND project_id = ? AND cluster_id = ?",
+		id,
+		projID,
+		clusterID,
+	).First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptEventData(event, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ListEventsByProjectID finds all events for a given project id
+// with the given options
+func (repo *EventRepository) ListEventsByProjectID(
+	projectID uint,
+	opts *repository.ListEventOpts,
+) ([]*models.Event, error) {
+	listOpts := opts
+
+	if listOpts.Limit == 0 {
+		listOpts.Limit = 50
+	}
+
+	events := []*models.Event{}
+
+	query := repo.db.Where("project_id = ? AND cluster_id = ?", projectID, opts.ClusterID)
+
+	if listOpts.Type != "" {
+		query = query.Where(
+			"ref_type = ?",
+			strings.ToLower(listOpts.Type),
+		)
+	}
+
+	if listOpts.OwnerName != "" && listOpts.OwnerType != "" {
+		query = query.Where(
+			"owner_name = ? AND owner_type = ?",
+			listOpts.OwnerName,
+			listOpts.OwnerType,
+		)
+	}
+
+	query = query.Limit(listOpts.Limit).Offset(listOpts.Skip)
+
+	if listOpts.SortBy == "timestamp" {
+		query = query.Order("timestamp desc").Order("id desc")
+	}
+
+	if err := query.Find(&events).Error; err != nil {
+		return nil, err
+	}
+
+	if opts.Decrypt {
+		for _, event := range events {
+			repo.DecryptEventData(event, repo.key)
+		}
+	}
+
+	return events, nil
+}
+
+// DeleteEvent deletes an event by ID
+func (repo *EventRepository) DeleteEvent(
+	id uint,
+) error {
+	if err := repo.db.Where("id = ?", id).Delete(&models.Event{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// EncryptEventData will encrypt the event data before
+// writing to the DB
+func (repo *EventRepository) EncryptEventData(
+	event *models.Event,
+	key *[32]byte,
+) error {
+	if len(event.Data) > 0 {
+		cipherData, err := repository.Encrypt(event.Data, key)
+
+		if err != nil {
+			return err
+		}
+
+		event.Data = cipherData
+	}
+
+	return nil
+}
+
+// DecryptEventData will decrypt the event data before
+// returning it from the DB
+func (repo *EventRepository) DecryptEventData(
+	event *models.Event,
+	key *[32]byte,
+) error {
+	if len(event.Data) > 0 {
+		plaintext, err := repository.Decrypt(event.Data, key)
+
+		if err != nil {
+			return err
+		}
+
+		event.Data = plaintext
+	}
+
+	return nil
+}

+ 153 - 0
internal/repository/gorm/event_test.go

@@ -0,0 +1,153 @@
+package gorm_test
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func TestCreateEvent(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_event.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	event := &models.Event{
+		ProjectID:    tester.initProjects[0].Model.ID,
+		ClusterID:    tester.initClusters[0].Model.ID,
+		RefType:      "pod",
+		RefName:      "pod-example-1",
+		RefNamespace: "default",
+		Message:      "Pod killed",
+		Reason:       "OOM: memory limit exceeded",
+		Data:         []byte("log from pod\nlog2 from pod"),
+		Expiry:       time.Now().Add(24 * time.Hour),
+	}
+
+	copyEvent := *event
+
+	event, err := tester.repo.Event.CreateEvent(event)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	event, err = tester.repo.Event.ReadEvent(event.Model.ID, 1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if event.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, event.Model.ID)
+	}
+
+	event.Model = gorm.Model{}
+
+	if diff := deep.Equal(event, &copyEvent); diff != nil {
+		t.Errorf("tokens not equal:")
+		t.Error(diff)
+	}
+}
+
+func TestListEventsByProjectIDWithLimit(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     10,
+		Type:      "node",
+		Decrypt:   true,
+	}, tester.initEvents[50:60])
+}
+
+func TestListEventsByProjectIDWithSkip(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     25,
+		Skip:      10,
+		Decrypt:   true,
+	}, tester.initEvents[10:35])
+}
+
+func TestListEventsByProjectIDWithSortBy(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListEventsByProjectID(tester, t, &repository.ListEventOpts{
+		ClusterID: 1,
+		Limit:     1,
+		Skip:      0,
+		Type:      "node",
+		Decrypt:   true,
+		SortBy:    "timestamp",
+	}, tester.initEvents[99:])
+}
+
+func testListEventsByProjectID(tester *tester, t *testing.T, opts *repository.ListEventOpts, expEvents []*models.Event) {
+	t.Helper()
+
+	events, err := tester.repo.Event.ListEventsByProjectID(
+		tester.initProjects[0].Model.ID,
+		opts,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure data is correct
+	if len(events) != len(expEvents) {
+		t.Fatalf("length of events incorrect: expected %d, got %d\n", len(expEvents), len(events))
+	}
+
+	for _, expEvent := range expEvents {
+		expEvent.Data = []byte("log from pod\nlog2 from pod")
+	}
+
+	if diff := deep.Equal(expEvents, events); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}

+ 46 - 0
internal/repository/gorm/helpers_test.go

@@ -1,6 +1,7 @@
 package gorm_test
 
 import (
+	"fmt"
 	"os"
 	"testing"
 	"time"
@@ -22,6 +23,7 @@ type tester struct {
 	initGRs      []*models.GitRepo
 	initRegs     []*models.Registry
 	initClusters []*models.Cluster
+	initEvents   []*models.Event
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
 	initReleases []*models.Release
@@ -63,6 +65,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.Event{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -496,6 +499,49 @@ func initInfra(tester *tester, t *testing.T) {
 	tester.initInfras = append(tester.initInfras, infra)
 }
 
+func initEvents(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	initEvents := make([]*models.Event, 0)
+
+	// init 100 events for testing purposes
+	for i := 0; i < 100; i++ {
+		refType := "pod"
+
+		if i >= 50 {
+			refType = "node"
+		}
+
+		event := &models.Event{
+			ProjectID:    tester.initProjects[0].Model.ID,
+			ClusterID:    tester.initClusters[0].Model.ID,
+			RefType:      refType,
+			RefName:      fmt.Sprintf("%s-example-%d", refType, i),
+			RefNamespace: "default",
+			Message:      "Pod killed",
+			Reason:       "OOM: memory limit exceeded",
+			Data:         []byte("log from pod\nlog2 from pod"),
+			Expiry:       expiry,
+		}
+
+		event, err := tester.repo.Event.CreateEvent(event)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		initEvents = append(initEvents, event)
+	}
+
+	tester.initEvents = initEvents
+}
+
 func initInvite(tester *tester, t *testing.T) {
 	t.Helper()
 

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

@@ -26,6 +26,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.AuthCode{},
 		&models.DNSRecord{},
 		&models.PWResetToken{},
+		&models.Event{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

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

@@ -21,6 +21,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GitActionConfig:           NewGitActionConfigRepository(db),
 		Invite:                    NewInviteRepository(db),
 		AuthCode:                  NewAuthCodeRepository(db),
+		Event:                     NewEventRepository(db, key),
 		DNSRecord:                 NewDNSRecordRepository(db),
 		PWResetToken:              NewPWResetTokenRepository(db),
 		KubeIntegration:           NewKubeIntegrationRepository(db, key),

+ 1 - 0
internal/repository/repository.go

@@ -15,6 +15,7 @@ type Repository struct {
 	Invite                    InviteRepository
 	AuthCode                  AuthCodeRepository
 	DNSRecord                 DNSRecordRepository
+	Event                     EventRepository
 	PWResetToken              PWResetTokenRepository
 	KubeIntegration           KubeIntegrationRepository
 	BasicIntegration          BasicIntegrationRepository

+ 170 - 0
server/api/event_handler.go

@@ -0,0 +1,170 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/gorilla/schema"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// HandleCreateEvent creates a new event in a project
+func (app *App) HandleCreateEvent(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
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	form := &forms.CreateEventForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an invite
+	event := form.ToEvent(uint(projID), uint(clusterID))
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	event, err = app.Repo.Event.CreateEvent(event)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+}
+
+// HandleListEvents lists the events that match certain conditions in a project
+func (app *App) HandleListEvents(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
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	opts := &repository.ListEventOpts{
+		ClusterID: uint(clusterID),
+	}
+
+	decoder := schema.NewDecoder()
+
+	decoder.IgnoreUnknownKeys(true)
+
+	if err := decoder.Decode(opts, vals); err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"bad request"},
+		}, w)
+	}
+
+	// handle write to the database
+	events, err := app.Repo.Event.ListEventsByProjectID(uint(projID), opts)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	eventExts := make([]*models.EventExternalSimple, 0)
+
+	for _, event := range events {
+		eventExts = append(eventExts, event.ExternalizeSimple())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(eventExts); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListEvents lists the events that match certain conditions in a project
+func (app *App) HandleGetEvent(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
+	}
+
+	clusterID, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"cluster not found"},
+		}, w)
+	}
+
+	eventID, err := strconv.ParseUint(chi.URLParam(r, "event_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	event, err := app.Repo.Event.ReadEvent(uint(eventID), uint(projID), uint(clusterID))
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	eventExt := event.Externalize()
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(eventExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 43 - 0
server/router/router.go

@@ -406,6 +406,49 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			// /api/projects/{project_id}/events routes
+			r.Method(
+				"POST",
+				"/projects/{project_id}/clusters/{cluster_id}/events",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateEvent, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/events",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleListEvents, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/events/{event_id}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetEvent, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/invites routes
 			r.Method(
 				"POST",