diff --git a/events_actor_test.go b/events_actor_test.go new file mode 100644 index 0000000..ba37b19 --- /dev/null +++ b/events_actor_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEventActor covers the audit trail: every write leaves an event row +// carrying the subject of the token that authenticated the request. +func TestEventActor(t *testing.T) { + const ( + deletablePublisherID = "15fda7c4-6bbf-4387-8f89-258c1e6fafb1" + deletableSoftwareID = "11e101c4-f989-4cc4-a665-63f9f34e83f6" + ) + + tests := []struct { + description string + method string + path string + body string + subject string + // entityID is the row the event is expected for. Left empty on a + // POST, which only learns the id from the response. + entityID string + expectedCode int + expectedActor string + }{ + { + description: "POST publisher", + method: http.MethodPost, + path: "/v1/publishers", + body: `{"description":"actor test publisher","codeHosting":[{"url":"https://actor-test.example.org/repo"}]}`, + subject: "crawler", + expectedCode: 200, + expectedActor: "crawler", + }, + { + description: "POST software", + method: http.MethodPost, + path: "/v1/software", + body: `{"publiccodeYml":"-","url":"https://actor-test.example.org/software"}`, + subject: "crawler", + expectedCode: 200, + expectedActor: "crawler", + }, + { + description: "POST publisher in a catalog", + method: http.MethodPost, + path: "/v1/catalogs/" + italiaID + "/publishers", + body: `{"description":"actor test catalog publisher","codeHosting":[{"url":"https://actor-test.example.org/catalog-repo"}]}`, + subject: "curator", + expectedCode: 200, + expectedActor: "curator", + }, + { + description: "PATCH publisher", + method: http.MethodPatch, + path: publisherPath + italiaPublisherID, + body: `{"description":"actor test patched description"}`, + subject: "editor", + entityID: italiaPublisherID, + expectedCode: 200, + expectedActor: "editor", + }, + { + description: "PATCH software", + method: http.MethodPatch, + path: softwarePath + swissSoftwareID, + body: `{"vitality":"10,10,10"}`, + subject: "editor", + entityID: swissSoftwareID, + expectedCode: 200, + expectedActor: "editor", + }, + { + description: "DELETE publisher", + method: http.MethodDelete, + path: publisherPath + deletablePublisherID, + subject: "janitor", + entityID: deletablePublisherID, + expectedCode: 204, + expectedActor: "janitor", + }, + { + description: "DELETE software", + method: http.MethodDelete, + path: softwarePath + deletableSoftwareID, + subject: "janitor", + entityID: deletableSoftwareID, + expectedCode: 204, + expectedActor: "janitor", + }, + { + description: "POST publisher with a token carrying no subject", + method: http.MethodPost, + path: "/v1/publishers", + body: `{"description":"actor test anonymous publisher","codeHosting":[{"url":"https://actor-test.example.org/anonymous"}]}`, + subject: "", + expectedCode: 200, + expectedActor: "", + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + loadFixtures(t) + + req, err := newTestRequest(test.method, test.path, strings.NewReader(test.body)) + require.NoError(t, err) + + req.Header = map[string][]string{ + "Authorization": {bearerWithSubject(t, test.subject)}, + "Content-Type": {"application/json"}, + } + + res, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, test.expectedCode, res.StatusCode) + + entityID := test.entityID + if entityID == "" { + entityID = idFromResponse(t, res.Body) + } + + require.Equal(t, 1, dbCount(t, "events", "entity_id", entityID)) + assert.Equal(t, test.expectedActor, dbValue(t, "events", "actor", "entity_id", entityID)) + }) + } +} + +// idFromResponse reads the id of the entity a POST created. +func idFromResponse(t *testing.T, body io.Reader) string { + t.Helper() + + raw, err := io.ReadAll(body) + require.NoError(t, err) + + id, ok := decodeJSON(t, raw)["id"].(string) + require.True(t, ok, "no id in the response: %s", raw) + + return id +} diff --git a/events_test.go b/events_test.go new file mode 100644 index 0000000..52acd6d --- /dev/null +++ b/events_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventsEndpoints(t *testing.T) { + const ( + eventWithActorID = "0ab7b216-d819-4a2a-8258-65c7dbe3af4d" + eventWithoutActorID = "d37d1082-528e-449d-a626-445561368d6b" + eventCount = 8 + ) + + authHeaders := map[string][]string{"Authorization": {goodToken}} + + // The actor of every fixture event, empty for the rows the fixtures + // leave without one. + actors := map[string]string{ + "d5e6f708-91a2-4bde-8f30-6b7c8d9e0f75": "crawler", + "c4d5e6f7-8091-4cad-9e2f-5a6b7c8d9e74": "", + "b3c4d5e6-7f80-4b9c-8d1e-4f5a6b7c8d73": "crawler", + "a2b3c4d5-6e7f-4a8b-9c0d-3e4f5a6b7c72": "editor", + eventWithActorID: "crawler", + eventWithoutActorID: "", + "9b1c2d34-4e5f-4a6b-8c7d-2e3f4a5b6c71": "editor", + "8e0a1f56-3a70-4b6e-9a2d-1f3b4c5d6e70": "crawler", + } + + tests := []TestCase{ + // GET /events + { + description: "GET events without a token", + query: "GET /v1/events", + expectedCode: 401, + expectedBody: `{"title":"token authentication failed","status":401}`, + expectedContentType: "application/problem+json", + }, + { + description: "GET events", + query: "GET /v1/events", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, eventCount, len(data)) + + // The whole fixture fits into the default page of 25. + assertPaginationLinks(t, response, nil, nil) + + var prevCreatedAt *time.Time + + for _, event := range data { + assertUUID(t, event["id"]) + + assert.Contains(t, []any{"create", "update", "delete"}, event["type"]) + assert.Contains(t, []any{"software", "publishers"}, event["entityType"]) + assertUUID(t, event["entityId"]) + + createdAt := assertRFC3339(t, event["createdAt"]) + assertRFC3339(t, event["updatedAt"]) + + id, ok := event["id"].(string) + require.True(t, ok) + + expectedActor, known := actors[id] + require.True(t, known, "unexpected event %q in the response", id) + assertActor(t, event, expectedActor) + + assertOnlyKeys(t, event, "id", "type", "entityType", "entityId", "actor", "createdAt", "updatedAt") + + if prevCreatedAt != nil { + assert.GreaterOrEqual(t, *prevCreatedAt, createdAt) + } + + prevCreatedAt = &createdAt + } + }, + }, + { + description: "GET events with page[size] query param", + query: "GET /v1/events?page[size]=3", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, 3, len(data)) + + assertPaginationLinks(t, response, nil, "?page[after]=WyIyMDE5LTA5LTE1VDAwOjAwOjAwWiIsImIzYzRkNWU2LTdmODAtNGI5Yy04ZDFlLTRmNWE2YjdjOGQ3MyJd&page[size]=3") + }, + }, + { + description: "GET events with page[after] query param", + query: "GET /v1/events?page[after]=WyIyMDE5LTA5LTE1VDAwOjAwOjAwWiIsImIzYzRkNWU2LTdmODAtNGI5Yy04ZDFlLTRmNWE2YjdjOGQ3MyJd", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, 5, len(data)) + + assertPaginationLinks(t, response, "?page[before]=WyIyMDE4LTExLTMwVDAwOjAwOjAwWiIsImEyYjNjNGQ1LTZlN2YtNGE4Yi05YzBkLTNlNGY1YTZiN2M3MiJd", nil) + }, + }, + { + description: `GET events with "from" query param`, + query: "GET /v1/events?from=2019-01-01T00:00:00Z", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, 3, len(data)) + }, + }, + { + description: `GET events with invalid "from" query param`, + query: "GET /v1/events?from=3", + headers: authHeaders, + expectedCode: 422, + expectedContentType: "application/problem+json", + validateFunc: func(t *testing.T, response map[string]any) { + assert.Equal(t, `can't get Events`, response["title"]) + assert.Equal(t, "invalid date time format (RFC 3339 needed)", response["detail"]) + }, + }, + { + description: `GET events with "to" query param`, + query: "GET /v1/events?to=2019-01-01T00:00:00Z", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, 5, len(data)) + }, + }, + { + description: `GET events with invalid "to" query param`, + query: "GET /v1/events?to=3", + headers: authHeaders, + expectedCode: 422, + expectedContentType: "application/problem+json", + validateFunc: func(t *testing.T, response map[string]any) { + assert.Equal(t, `can't get Events`, response["title"]) + assert.Equal(t, "invalid date time format (RFC 3339 needed)", response["detail"]) + }, + }, + { + description: `GET events with "from" and "to" query params`, + query: "GET /v1/events?from=2016-01-01T00:00:00Z&to=2019-01-01T00:00:00Z", + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + data := assertListResponse(t, response) + + assert.Equal(t, 4, len(data)) + }, + }, + + // GET /events/:id + { + description: "GET event without a token", + query: "GET /v1/events/" + eventWithActorID, + expectedCode: 401, + expectedBody: `{"title":"token authentication failed","status":401}`, + expectedContentType: "application/problem+json", + }, + { + description: "GET event with an actor", + query: "GET /v1/events/" + eventWithActorID, + headers: authHeaders, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + assert.Equal(t, eventWithActorID, response["id"]) + assert.Equal(t, "update", response["type"]) + assert.Equal(t, "software", response["entityType"]) + assert.Equal(t, "c5dec6fa-8a01-4881-9e7d-132770d4214d", response["entityId"]) + assert.Equal(t, "crawler", response["actor"]) + + assertTimestamps(t, response) + assertOnlyKeys(t, response, "id", "type", "entityType", "entityId", "actor", "createdAt", "updatedAt") + }, + }, + { + description: "GET event with no actor", + query: "GET /v1/events/" + eventWithoutActorID, + headers: authHeaders, + setupFunc: func(t *testing.T) { + assert.True(t, dbNull(t, "events", "actor", "id", eventWithoutActorID)) + }, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]any) { + assert.Equal(t, eventWithoutActorID, response["id"]) + assert.Equal(t, "create", response["type"]) + + assertActor(t, response, "") + assertOnlyKeys(t, response, "id", "type", "entityType", "entityId", "createdAt", "updatedAt") + }, + }, + { + description: "GET non-existent event", + query: "GET /v1/events/eea19c82-0449-11ed-bd84-d8bbc146d165", + headers: authHeaders, + expectedCode: 404, + expectedBody: `{"title":"can't get Event","detail":"Event was not found","status":404}`, + expectedContentType: "application/problem+json", + }, + } + + runTestCases(t, tests) +} + +// assertActor checks the actor of an event, which is absent from the +// response when the token that made the write carried no subject. +func assertActor(t *testing.T, event map[string]any, expected string) { + t.Helper() + + actor, present := event["actor"] + + if expected == "" { + assert.False(t, present, "expected no actor, got %v", actor) + + return + } + + assert.Equal(t, expected, actor) +} diff --git a/internal/common/actor.go b/internal/common/actor.go new file mode 100644 index 0000000..4e2a74e --- /dev/null +++ b/internal/common/actor.go @@ -0,0 +1,25 @@ +package common + +import "context" + +// actorKey addresses the identity of whoever authenticated the request, +// so a database hook running far from the handler can still record who +// asked for the change. +type actorKey struct{} + +// WithActor returns a context carrying actor. +func WithActor(ctx context.Context, actor string) context.Context { + return context.WithValue(ctx, actorKey{}, actor) +} + +// Actor returns the actor carried by ctx. An unauthenticated request and +// a token with no subject both give an empty string. +func Actor(ctx context.Context) string { + if ctx == nil { + return "" + } + + actor, _ := ctx.Value(actorKey{}).(string) + + return actor +} diff --git a/internal/common/actor_test.go b/internal/common/actor_test.go new file mode 100644 index 0000000..7affde4 --- /dev/null +++ b/internal/common/actor_test.go @@ -0,0 +1,62 @@ +package common + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestActor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ctx context.Context //nolint:containedctx // the table is the input + expected string + }{ + { + name: "a nil context has no actor", + ctx: nil, + expected: "", + }, + { + name: "a context nobody wrote to has no actor", + ctx: context.Background(), + expected: "", + }, + { + name: "the actor comes back as it was stored", + ctx: WithActor(context.Background(), "crawler"), + expected: "crawler", + }, + { + name: "an empty actor stays empty", + ctx: WithActor(context.Background(), ""), + expected: "", + }, + { + name: "the last actor wins", + ctx: WithActor(WithActor(context.Background(), "crawler"), "editor"), + expected: "editor", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expected, Actor(test.ctx)) + }) + } +} + +func TestActorIgnoresAValueStoredUnderAnotherKey(t *testing.T) { + t.Parallel() + + type otherKey struct{} + + ctx := context.WithValue(context.Background(), otherKey{}, "crawler") + + assert.Empty(t, Actor(ctx)) +} diff --git a/internal/handlers/catalogs.go b/internal/handlers/catalogs.go index 9c20290..6b9b3a8 100644 --- a/internal/handlers/catalogs.go +++ b/internal/handlers/catalogs.go @@ -320,7 +320,7 @@ func (c *Catalog) PostCatalogPublisher(ctx *fiber.Ctx) error { return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message) } - return createPublisher(ctx, c.db, catalogOwnerID(catalog)) + return createPublisher(ctx, writeDB(ctx, c.db), catalogOwnerID(catalog)) } // PatchCatalogPublisher updates a publisher that belongs to the given catalog. @@ -350,7 +350,7 @@ func (c *Catalog) PatchCatalogPublisher(ctx *fiber.Ctx) error { return common.Error(fiber.StatusNotFound, errMsg, "Publisher was not found") } - return updatePublisher(ctx, c.db, *found) + return updatePublisher(ctx, writeDB(ctx, c.db), *found) } // PostCatalogSoftware creates software belonging to the given catalog. @@ -367,7 +367,7 @@ func (c *Catalog) PostCatalogSoftware(ctx *fiber.Ctx) error { return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message) } - return createSoftware(ctx, c.db, catalogOwnerID(catalog)) + return createSoftware(ctx, writeDB(ctx, c.db), catalogOwnerID(catalog)) } // PatchCatalogSoftware updates software that belongs to the given catalog. @@ -397,7 +397,7 @@ func (c *Catalog) PatchCatalogSoftware(ctx *fiber.Ctx) error { return common.Error(fiber.StatusNotFound, errMsg, "Software was not found") } - return updateSoftware(ctx, c.db, software) + return updateSoftware(ctx, writeDB(ctx, c.db), software) } // GetCatalogSoftware lists software belonging to the given catalog. diff --git a/internal/handlers/crud.go b/internal/handlers/crud.go index f24351b..e31ae5d 100644 --- a/internal/handlers/crud.go +++ b/internal/handlers/crud.go @@ -101,6 +101,12 @@ const ( codeHostingAssociation = "CodeHosting" ) +// writeDB is how the actor reaches the hooks that record an event: the +// statement context is the only context they get to look at. +func writeDB(ctx *fiber.Ctx, gormdb *gorm.DB) *gorm.DB { + return gormdb.WithContext(ctx.UserContext()) +} + // findOptions drives findOne. type findOptions struct { // title is the Problem JSON title on failure, e.g. "can't get Publisher". diff --git a/internal/handlers/events.go b/internal/handlers/events.go new file mode 100644 index 0000000..88caff0 --- /dev/null +++ b/internal/handlers/events.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/pilagod/gorm-cursor-paginator/v2/paginator" + "github.com/publiccodeyml/open-catalog-api/internal/models" + "gorm.io/gorm" +) + +type Event struct { + db *gorm.DB +} + +func NewEvent(db *gorm.DB) *Event { + return &Event{db: db} +} + +// GetEvents gets the list of all events and returns any error encountered. +func (e *Event) GetEvents(ctx *fiber.Ctx) error { + return list[models.Event](ctx, e.db, listOptions{ + title: "can't get Events", + order: paginator.DESC, + }) +} + +// GetEvent gets the event with the given ID and returns any error encountered. +func (e *Event) GetEvent(ctx *fiber.Ctx) error { + event, err := findOne[models.Event](e.db, ctx.Params("id"), findOptions{title: "can't get Event", name: "Event"}) + if err != nil { + return err + } + + return ctx.JSON(event) +} diff --git a/internal/handlers/publishers.go b/internal/handlers/publishers.go index 1c7db03..625180f 100644 --- a/internal/handlers/publishers.go +++ b/internal/handlers/publishers.go @@ -47,7 +47,7 @@ func (p *Publisher) GetPublisher(ctx *fiber.Ctx) error { // PostPublisher creates a new publisher. func (p *Publisher) PostPublisher(ctx *fiber.Ctx) error { - return createPublisher(ctx, p.db, nil) + return createPublisher(ctx, writeDB(ctx, p.db), nil) } // PatchPublisher updates the publisher with the given ID. @@ -64,7 +64,7 @@ func (p *Publisher) PatchPublisher(ctx *fiber.Ctx) error { return err } - return updatePublisher(ctx, p.db, *found) + return updatePublisher(ctx, writeDB(ctx, p.db), *found) } // DeletePublisher deletes the publisher with the given ID. @@ -80,7 +80,7 @@ func (p *Publisher) DeletePublisher(ctx *fiber.Ctx) error { publisher := *found - if err := models.Transaction(p.db, func(tran *gorm.DB) error { + if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error { return tran.Select(codeHostingAssociation).Delete(&publisher).Error }); err != nil { return common.Error(fiber.StatusInternalServerError, "can't delete Publisher", "db error") diff --git a/internal/handlers/software.go b/internal/handlers/software.go index 3f3e4c0..35c789a 100644 --- a/internal/handlers/software.go +++ b/internal/handlers/software.go @@ -67,7 +67,7 @@ func (p *Software) GetSoftware(ctx *fiber.Ctx) error { // PostSoftware creates a new software. func (p *Software) PostSoftware(ctx *fiber.Ctx) error { - return createSoftware(ctx, p.db, nil) + return createSoftware(ctx, writeDB(ctx, p.db), nil) } // PatchSoftware updates the software with the given ID. @@ -84,14 +84,14 @@ func (p *Software) PatchSoftware(ctx *fiber.Ctx) error { return common.Error(fiber.StatusInternalServerError, errMsg, fiber.ErrInternalServerError.Message) } - return updateSoftware(ctx, p.db, software) + return updateSoftware(ctx, writeDB(ctx, p.db), software) } // DeleteSoftware deletes the software with the given ID. func (p *Software) DeleteSoftware(ctx *fiber.Ctx) error { var rowsAffected int64 - if err := models.Transaction(p.db, func(tran *gorm.DB) error { + if err := models.Transaction(writeDB(ctx, p.db), func(tran *gorm.DB) error { result := tran.Select("Aliases", "Bundles").Delete(&models.Software{ID: ctx.Params("id")}) rowsAffected = result.RowsAffected diff --git a/internal/middleware/paseto.go b/internal/middleware/paseto.go index ce69b8f..eb38472 100644 --- a/internal/middleware/paseto.go +++ b/internal/middleware/paseto.go @@ -44,6 +44,16 @@ func NewPasetoMiddleware( return payload, nil }, + SuccessHandler: func(ctx *fiber.Ctx) error { + // The token is the only place the identity of the caller comes + // from, and the model hooks recording an event read it off the + // request context. + if payload, ok := ctx.Locals(pasetoware.DefaultContextKey).(paseto.JSONToken); ok { + ctx.SetUserContext(common.WithActor(ctx.UserContext(), payload.Subject)) + } + + return ctx.Next() + }, ErrorHandler: func(ctx *fiber.Ctx, _ error) error { return common.CustomErrorHandler(ctx, common.ErrAuthentication) }, diff --git a/internal/models/hooks.go b/internal/models/hooks.go index afd6545..3d4cca2 100644 --- a/internal/models/hooks.go +++ b/internal/models/hooks.go @@ -84,6 +84,10 @@ func emit(trx *gorm.DB, eventType string, model Model) error { EntityID: model.UUID(), } + if trx.Statement != nil { + event.Actor = common.Actor(trx.Statement.Context) + } + if err := trx.Create(&event).Error; err != nil { return err } diff --git a/internal/models/models.go b/internal/models/models.go index 7e1fff2..5d51294 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -216,12 +216,15 @@ type Webhook struct { EntityType string `json:"-" gorm:"index:idx_webhook_url,unique"` } +// Event is one entry of the audit trail. Actor is the subject of the +// token that authenticated the write, empty when the token carries none. type Event struct { - ID string `gorm:"primaryKey"` - Type string - EntityType string - EntityID string - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` + ID string `json:"id" gorm:"primaryKey"` + Type string `json:"type"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + Actor string `json:"actor,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } diff --git a/main.go b/main.go index 8920c06..95fdbb0 100644 --- a/main.go +++ b/main.go @@ -157,8 +157,8 @@ func Setup() (*fiber.App, *webhooks.Debouncer) { } // requiresAuth reports whether a request must carry a valid token. Reads -// are public except for webhook configuration, whose GET operations are -// marked as authenticated in the OpenAPI contract. +// are public except for webhook configuration and the audit trail, whose +// GET operations are marked as authenticated in the OpenAPI contract. func requiresAuth(method, requestPath string) bool { if method != fiber.MethodGet { return true @@ -166,6 +166,8 @@ func requiresAuth(method, requestPath string) bool { normalizedPath := "/" + strings.Trim(requestPath, "/") for _, pattern := range [...]string{ + "/v1/events", + "/v1/events/*", "/v1/webhooks/*", "/v1/software/webhooks", "/v1/software/*/webhooks", @@ -203,6 +205,7 @@ func setupHandlers(app *fiber.App, gormDB *gorm.DB) { //nolint:funlen softwareHandler := handlers.NewSoftware(gormDB) statusHandler := handlers.NewStatus(gormDB) logHandler := handlers.NewLog(gormDB) + eventHandler := handlers.NewEvent(gormDB) publisherWebhookHandler := handlers.NewWebhook[models.Publisher](gormDB) softwareWebhookHandler := handlers.NewWebhook[models.Software](gormDB) @@ -262,6 +265,9 @@ func setupHandlers(app *fiber.App, gormDB *gorm.DB) { //nolint:funlen v1.Get("/software/:id/logs", logHandler.GetSoftwareLogs) v1.Post("/software/:id/logs", logHandler.PostSoftwareLog) + v1.Get("/events", eventHandler.GetEvents) + v1.Get("/events/:id", eventHandler.GetEvent) + v1.Get("/status", statusHandler.GetStatus) v1.Get("/webhooks/:id", publisherWebhookHandler.GetWebhook) diff --git a/main_test.go b/main_test.go index c1690b3..bef205e 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "encoding/base64" "encoding/json" "fmt" "io" @@ -19,6 +20,7 @@ import ( "github.com/gofiber/fiber/v2" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" + "github.com/o1egl/paseto" "github.com/publiccodeyml/open-catalog-api/internal/common" "github.com/publiccodeyml/open-catalog-api/internal/database" "github.com/stretchr/testify/assert" @@ -115,6 +117,24 @@ func newTestRequest(method, url string, body io.Reader) (*http.Request, error) { return req, nil } +// bearerWithSubject mints a token signed with the test PASETO key and +// returns it ready for the Authorization header, so a test can choose the +// subject the API records as the actor of a write. +func bearerWithSubject(t *testing.T, subject string) string { + t.Helper() + + key, err := base64.StdEncoding.DecodeString(os.Getenv("PASETO_KEY")) + require.NoError(t, err) + + token, err := paseto.NewV2().Encrypt(key, paseto.JSONToken{ + Subject: subject, + IssuedAt: time.Now().UTC().Add(-time.Minute), + }, nil) + require.NoError(t, err) + + return "Bearer " + token +} + func loadFixtures(t *testing.T) { t.Helper() fixtures, err := testfixtures.New( @@ -404,6 +424,8 @@ func TestRequiresAuth(t *testing.T) { {name: "all publisher webhooks", method: http.MethodGet, path: "/v1/publishers/webhooks", required: true}, {name: "one publisher webhooks", method: http.MethodGet, path: "/v1/publishers/id/webhooks", required: true}, {name: "webhook by id", method: http.MethodGet, path: "/v1/webhooks/id", required: true}, + {name: "event collection", method: http.MethodGet, path: "/v1/events", required: true}, + {name: "event by id", method: http.MethodGet, path: "/v1/events/id", required: true}, {name: "trailing slash", method: http.MethodGet, path: "/v1/webhooks/id/", required: true}, {name: "webhook collection without route", method: http.MethodGet, path: "/v1/webhooks", required: false}, {name: "unrelated nested resource", method: http.MethodGet, path: "/v1/software/id/logs", required: false}, diff --git a/open-catalog-api.oas.yaml b/open-catalog-api.oas.yaml index 6ee1534..a1e57ee 100644 --- a/open-catalog-api.oas.yaml +++ b/open-catalog-api.oas.yaml @@ -40,6 +40,10 @@ tags: description: Operations on software - name: logs description: Operations on logs + - name: events + description: > + Operations on events, the audit trail of every create, update and + delete, who did it and when - name: publishers description: Operations on publishers - name: monitor @@ -1040,6 +1044,114 @@ paths: $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/TooManyRequests' + /events: + get: + summary: List all Events + description: > + List all Events. The Events are ordered from the most recent + to the least recent. + tags: + - events + security: + - bearerAuth: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + data: + type: array + description: List of results for the current page + minItems: 0 + maxItems: 100 + items: + $ref: '#/components/schemas/Event' + links: + $ref: '#/components/schemas/Links' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '429': + $ref: '#/components/responses/TooManyRequests' + operationId: list-events + parameters: + - schema: + type: integer + format: int32 + example: 100 + minimum: 1 + maximum: 100 + default: 25 + in: query + name: 'page[size]' + description: Limit the amount of results + - schema: + type: string + maxLength: 255 + pattern: '.*' + in: query + name: 'page[before]' + description: Only results before this cursor + example: 'WyIyMDIyLTA2LTA3VDE0OjU2OjIzWiIsImJmZjEyMzQ1Il0=' + - schema: + type: string + maxLength: 255 + pattern: '.*' + in: query + name: 'page[after]' + description: Only results after this cursor + example: 'WyIyMDIyLTA2LTA3VDE0OjU2OjIzWiIsImFhYTEyMzQ1Il0=' + - schema: + type: string + format: date-time + example: '2022-06-07T09:56:23Z' + in: query + name: from + description: Only events after this time (RFC 3339 datetime) + - schema: + type: string + format: date-time + example: '2022-06-07T14:56:23Z' + in: query + name: to + description: Only events before this time (RFC 3339 datetime) + '/events/{eventId}': + parameters: + - schema: + type: string + maxLength: 36 + pattern: '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}' + example: '12f30d9e-042e-41ed-8ddc-d8bbc146d165' + name: eventId + in: path + description: The ID of the Event + required: true + get: + summary: Get an Event + description: Get an Event from its id + tags: + - events + security: + - bearerAuth: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + operationId: get-event /catalogs: get: summary: List all Catalogs @@ -2934,6 +3046,69 @@ components: - createdAt - updatedAt - message + Event: + title: Event + type: object + additionalProperties: false + properties: + id: + type: string + maxLength: 36 + pattern: '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}' + description: Unique identifier of the Event + example: '12f30d9e-042e-41ed-8ddc-d8bbc146d165' + readOnly: true + type: + type: string + enum: + - create + - update + - delete + description: The kind of change the event records + example: 'update' + readOnly: true + entityType: + type: string + maxLength: 255 + pattern: '.*' + description: The resource collection the changed entity belongs to + example: 'software' + readOnly: true + entityId: + type: string + maxLength: 36 + pattern: '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}' + description: The ID of the changed entity + example: '7589be36-f046-45c6-9223-b7de9dbf06cd' + readOnly: true + actor: + type: string + maxLength: 255 + pattern: '.*' + description: > + The subject of the token that made the change, absent when the + token had none. + example: 'crawler' + readOnly: true + createdAt: + type: string + format: date-time + example: '2022-06-07T14:56:23Z' + description: The time the event was created (RFC 3339 datetime) + readOnly: true + updatedAt: + type: string + format: date-time + example: '2022-06-07T14:56:23Z' + description: The time the event was updated (RFC 3339 datetime) + readOnly: true + required: + - id + - type + - entityType + - entityId + - createdAt + - updatedAt Links: type: object additionalProperties: false diff --git a/test/testdata/fixtures/events.yml b/test/testdata/fixtures/events.yml index 994ee04..fa80c9c 100644 --- a/test/testdata/fixtures/events.yml +++ b/test/testdata/fixtures/events.yml @@ -1,14 +1,63 @@ --- +- id: 8e0a1f56-3a70-4b6e-9a2d-1f3b4c5d6e70 + type: "create" + entity_id: 47807e0c-0613-4aea-9917-5455cc6eddad + entity_type: publishers + actor: crawler + created_at: '2015-03-01T00:00:00+00:00' + updated_at: '2015-03-01T00:00:00+00:00' + +- id: 9b1c2d34-4e5f-4a6b-8c7d-2e3f4a5b6c71 + type: "update" + entity_id: 47807e0c-0613-4aea-9917-5455cc6eddad + entity_type: publishers + actor: editor + created_at: '2016-07-14T00:00:00+00:00' + updated_at: '2016-07-14T00:00:00+00:00' + +# No actor: the write was authenticated by a token carrying no subject. - id: d37d1082-528e-449d-a626-445561368d6b - type: "created" + type: "create" entity_id: c5dec6fa-8a01-4881-9e7d-132770d4214d entity_type: software created_at: '2017-05-01T00:00:00+00:00' updated_at: '2017-05-01T00:00:00+00:00' - id: 0ab7b216-d819-4a2a-8258-65c7dbe3af4d - type: "updated" + type: "update" entity_id: c5dec6fa-8a01-4881-9e7d-132770d4214d entity_type: software + actor: crawler created_at: '2017-05-02T00:00:00+00:00' updated_at: '2017-05-02T00:00:00+00:00' + +- id: a2b3c4d5-6e7f-4a8b-9c0d-3e4f5a6b7c72 + type: "delete" + entity_id: d6ddc11a-ff85-4f0f-bb87-df38b2a9b394 + entity_type: publishers + actor: editor + created_at: '2018-11-30T00:00:00+00:00' + updated_at: '2018-11-30T00:00:00+00:00' + +- id: b3c4d5e6-7f80-4b9c-8d1e-4f5a6b7c8d73 + type: "create" + entity_id: c353756e-8597-4e46-a99b-7da2e141603b + entity_type: software + actor: crawler + created_at: '2019-09-15T00:00:00+00:00' + updated_at: '2019-09-15T00:00:00+00:00' + +- id: c4d5e6f7-8091-4cad-9e2f-5a6b7c8d9e74 + type: "update" + entity_id: c353756e-8597-4e46-a99b-7da2e141603b + entity_type: software + created_at: '2021-02-20T00:00:00+00:00' + updated_at: '2021-02-20T00:00:00+00:00' + +- id: d5e6f708-91a2-4bde-8f30-6b7c8d9e0f75 + type: "delete" + entity_id: c353756e-8597-4e46-a99b-7da2e141603b + entity_type: software + actor: crawler + created_at: '2022-08-05T00:00:00+00:00' + updated_at: '2022-08-05T00:00:00+00:00'