diff options
| author | mo khan <mo@mokhan.ca> | 2025-07-24 17:58:01 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-07-24 17:58:01 -0600 |
| commit | 72296119fc9755774719f8f625ad03e0e0ec457a (patch) | |
| tree | ed236ddee12a20fb55b7cfecf13f62d3a000dcb5 /app | |
| parent | a920a8cfe415858bb2777371a77018599ffed23f (diff) | |
| parent | eaa1bd3b8e12934aed06413d75e7482ac58d805a (diff) | |
Merge branch 'the-spice-must-flow' into 'main'
Add SpiceDB Authorization
See merge request gitlab-org/software-supply-chain-security/authorization/sparkled!19
Diffstat (limited to 'app')
| -rw-r--r-- | app/controllers/sparkles/controller.go | 13 | ||||
| -rw-r--r-- | app/controllers/sparkles/controller_test.go | 118 | ||||
| -rw-r--r-- | app/db/in_memory_repository.go | 14 | ||||
| -rw-r--r-- | app/db/in_memory_repository_test.go | 29 | ||||
| -rw-r--r-- | app/domain/entity.go | 25 | ||||
| -rw-r--r-- | app/domain/entity_test.go | 40 | ||||
| -rw-r--r-- | app/domain/global_id.go | 31 | ||||
| -rw-r--r-- | app/domain/global_id_test.go | 44 | ||||
| -rw-r--r-- | app/domain/identifiable.go | 17 | ||||
| -rw-r--r-- | app/domain/permission.go | 21 | ||||
| -rw-r--r-- | app/domain/sparkle.go | 48 | ||||
| -rw-r--r-- | app/domain/sparkle_test.go | 60 | ||||
| -rw-r--r-- | app/domain/user.go | 18 | ||||
| -rw-r--r-- | app/init.go | 64 | ||||
| -rw-r--r-- | app/jobs/create_sparkle.go | 75 | ||||
| -rw-r--r-- | app/jobs/job.go | 5 | ||||
| -rw-r--r-- | app/middleware/permission.go | 24 | ||||
| -rw-r--r-- | app/middleware/require_permission.go | 15 | ||||
| -rw-r--r-- | app/middleware/require_permission_test.go | 25 |
19 files changed, 488 insertions, 198 deletions
diff --git a/app/controllers/sparkles/controller.go b/app/controllers/sparkles/controller.go index 0b86c7e..90767b2 100644 --- a/app/controllers/sparkles/controller.go +++ b/app/controllers/sparkles/controller.go @@ -9,15 +9,20 @@ import ( "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/middleware" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/authz" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" ) type Controller struct { - db domain.Repository[*domain.Sparkle] + db domain.Repository[*domain.Sparkle] + check authz.CheckPermissionService } -func New(db domain.Repository[*domain.Sparkle]) *Controller { - return &Controller{db: db} +func New(db domain.Repository[*domain.Sparkle], check authz.CheckPermissionService) *Controller { + return &Controller{ + check: check, + db: db, + } } func (c *Controller) MountTo(mux *http.ServeMux) { @@ -25,7 +30,7 @@ func (c *Controller) MountTo(mux *http.ServeMux) { mux.Handle("POST /sparkles", x.Middleware[http.Handler]( http.HandlerFunc(c.Create), middleware.RequireUser(), - // middleware.RequirePermission("create_sparkle", ioc.MustResolve[rpc.Ability](ioc.Default)), + // middleware.RequirePermission("create", c.check), )) // This is a temporary endpoint to restore a backup diff --git a/app/controllers/sparkles/controller_test.go b/app/controllers/sparkles/controller_test.go index 37e1a82..5c37a11 100644 --- a/app/controllers/sparkles/controller_test.go +++ b/app/controllers/sparkles/controller_test.go @@ -8,11 +8,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/xlgmokha/x/pkg/event" "github.com/xlgmokha/x/pkg/serde" "github.com/xlgmokha/x/pkg/test" + "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/cfg" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/db" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/internal/stub" ) type FailingResponseWriter struct { @@ -33,12 +36,12 @@ func (f *FailingResponseWriter) Write([]byte) (int, error) { func TestSparkles(t *testing.T) { t.Run("GET /sparkles", func(t *testing.T) { - sparkle, _ := domain.NewSparkle("@tanuki for helping me") - store := db.NewRepository[*domain.Sparkle]() + sparkle := x.New[*domain.Sparkle](domain.WithText("@tanuki for helping me")) + store := db.NewRepository[*domain.Sparkle](event.New[*domain.Sparkle]()) store.Save(t.Context(), sparkle) mux := http.NewServeMux() - controller := New(store) + controller := New(store, stub.AllowWith(t, "user:*", "read", "sparkle:*")) controller.MountTo(mux) t.Run("returns JSON", func(t *testing.T) { @@ -61,63 +64,84 @@ func TestSparkles(t *testing.T) { }) t.Run("POST /sparkles", func(t *testing.T) { - t.Run("when a user is logged in", func(t *testing.T) { - currentUser := domain.NewUser(domain.WithID[*domain.User](domain.ID("1"))) + t.Run("when a user is authenticated", func(t *testing.T) { + currentUser := x.New[*domain.User](domain.WithID[*domain.User](domain.ID("1"))) + repository := db.NewRepository[*domain.Sparkle](event.New[*domain.Sparkle]()) - t.Run("saves a new sparkle", func(t *testing.T) { - repository := db.NewRepository[*domain.Sparkle]() + t.Run("when the user is authorized", func(t *testing.T) { mux := http.NewServeMux() - controller := New(repository) + controller := New(repository, stub.AllowWith(t, "user:1", "create", "sparkle:*")) controller.MountTo(mux) - sparkle, _ := domain.NewSparkle("@tanuki for reviewing my code!") - request, response := test.RequestResponse( - "POST", - "/sparkles", - test.WithAcceptHeader(serde.JSON), - test.WithContentType(sparkle, serde.JSON), - test.WithContextKeyValue(t.Context(), cfg.CurrentUser, currentUser), - ) - - mux.ServeHTTP(response, request) - - require.Equal(t, http.StatusCreated, response.Code) - - t.Run("returns a JSON representation of the sparkle", func(t *testing.T) { - item, err := serde.FromJSON[*domain.Sparkle](response.Body) - require.NoError(t, err) - - assert.NotEmpty(t, item.ID) - assert.Equal(t, "@tanuki", item.Sparklee) - assert.Equal(t, "for reviewing my code!", item.Reason) + t.Run("saves a new sparkle", func(t *testing.T) { + sparkle := x.New[*domain.Sparkle](domain.WithText("@tanuki for reviewing my code!")) + request, response := test.RequestResponse( + "POST", + "/sparkles", + test.WithAcceptHeader(serde.JSON), + test.WithContentType(sparkle, serde.JSON), + test.WithContextKeyValue(t.Context(), cfg.CurrentUser, currentUser), + ) + + mux.ServeHTTP(response, request) + + require.Equal(t, http.StatusCreated, response.Code) + + t.Run("returns a JSON representation of the sparkle", func(t *testing.T) { + item, err := serde.FromJSON[*domain.Sparkle](response.Body) + require.NoError(t, err) + + assert.NotEmpty(t, item.ID) + assert.Equal(t, "@tanuki", item.Sparklee) + assert.Equal(t, "for reviewing my code!", item.Reason) + }) + + t.Run("saves the sparkle to the db", func(t *testing.T) { + assert.Equal(t, 1, len(repository.All(t.Context()))) + item := repository.All(t.Context())[0] + + assert.Equal(t, "@tanuki", item.Sparklee) + assert.Equal(t, "for reviewing my code!", item.Reason) + assert.Equal(t, currentUser, item.Author) + }) }) - t.Run("saves the sparkle to the db", func(t *testing.T) { - assert.Equal(t, 1, len(repository.All(t.Context()))) - item := repository.All(t.Context())[0] + t.Run("prevents double WriteHeader when serialization fails", func(t *testing.T) { + currentUser := x.New[*domain.User](domain.WithID[*domain.User](domain.ID("1"))) + sparkle := x.New[*domain.Sparkle](domain.WithText("@user for testing")) - assert.Equal(t, "@tanuki", item.Sparklee) - assert.Equal(t, "for reviewing my code!", item.Reason) - assert.Equal(t, currentUser, item.Author) + request, response := test.RequestResponse( + "POST", + "/sparkles", + test.WithAcceptHeader(serde.JSON), + test.WithContentType(sparkle, serde.JSON), + test.WithContextKeyValue(t.Context(), cfg.CurrentUser, currentUser), + ) + + mux.ServeHTTP(&FailingResponseWriter{T: t, ResponseRecorder: response}, request) }) }) - t.Run("prevents double WriteHeader when serialization fails", func(t *testing.T) { - repository := db.NewRepository[*domain.Sparkle]() - controller := New(repository) + t.Run("when the user is not authorized", func(t *testing.T) { + t.Skip() + mux := http.NewServeMux() + controller := New(repository, stub.Deny()) + controller.MountTo(mux) - currentUser := domain.NewUser(domain.WithID[*domain.User](domain.ID("1"))) - sparkle, _ := domain.NewSparkle("@user for testing") + t.Run("returns an error", func(t *testing.T) { + sparkle := x.New[*domain.Sparkle](domain.WithText("@tanuki for reviewing my code!")) + request, response := test.RequestResponse( + "POST", + "/sparkles", + test.WithAcceptHeader(serde.JSON), + test.WithContentType(sparkle, serde.JSON), + test.WithContextKeyValue(t.Context(), cfg.CurrentUser, currentUser), + ) - request, response := test.RequestResponse( - "POST", - "/sparkles", - test.WithAcceptHeader(serde.JSON), - test.WithContentType(sparkle, serde.JSON), - test.WithContextKeyValue(t.Context(), cfg.CurrentUser, currentUser), - ) + mux.ServeHTTP(response, request) - controller.Create(&FailingResponseWriter{T: t, ResponseRecorder: response}, request) + require.Equal(t, http.StatusForbidden, response.Code) + }) }) }) }) diff --git a/app/db/in_memory_repository.go b/app/db/in_memory_repository.go index 2aa1fed..1177662 100644 --- a/app/db/in_memory_repository.go +++ b/app/db/in_memory_repository.go @@ -5,20 +5,23 @@ import ( "sort" "sync" + "github.com/xlgmokha/x/pkg/event" "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" ) type inMemoryRepository[T domain.Entity] struct { - items []T - mu sync.RWMutex + aggregator *event.TypedAggregator[T] + items []T + mu sync.RWMutex } -func NewRepository[T domain.Entity]() domain.Repository[T] { +func NewRepository[T domain.Entity](aggregator *event.TypedAggregator[T]) domain.Repository[T] { return &inMemoryRepository[T]{ - items: []T{}, - mu: sync.RWMutex{}, + aggregator: aggregator, + items: []T{}, + mu: sync.RWMutex{}, } } @@ -50,5 +53,6 @@ func (r *inMemoryRepository[T]) Save(ctx context.Context, item T) error { sort.Slice(r.items, func(i, j int) bool { return r.items[i].GetID() > r.items[j].GetID() }) + r.aggregator.Publish("after.create", item) return nil } diff --git a/app/db/in_memory_repository_test.go b/app/db/in_memory_repository_test.go index cfbab41..5bb220e 100644 --- a/app/db/in_memory_repository_test.go +++ b/app/db/in_memory_repository_test.go @@ -7,11 +7,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/xlgmokha/x/pkg/event" + "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" ) func TestInMemoryRepository(t *testing.T) { - storage := NewRepository[*domain.Sparkle]() + aggregator := event.New[*domain.Sparkle]() + storage := NewRepository[*domain.Sparkle](aggregator) t.Run("Save", func(t *testing.T) { t.Run("an invalid Sparkle", func(t *testing.T) { @@ -30,8 +33,25 @@ func TestInMemoryRepository(t *testing.T) { assert.Equal(t, "because", sparkles[0].Reason) }) + t.Run("publishes an event", func(t *testing.T) { + called := false + var payload *domain.Sparkle + + aggregator.SubscribeTo("after.create", func(item *domain.Sparkle) { + called = true + payload = item + }) + + sparkle := &domain.Sparkle{Sparklee: "@tanuki", Reason: "because"} + require.NoError(t, storage.Save(t.Context(), sparkle)) + + require.True(t, called) + require.NotNil(t, payload) + assert.Equal(t, sparkle, payload) + }) + t.Run("prevents race conditions", func(t *testing.T) { - repository := NewRepository[*domain.Sparkle]() + repository := NewRepository[*domain.Sparkle](aggregator) ctx := context.Background() numGoroutines := 100 @@ -92,7 +112,7 @@ func TestInMemoryRepository(t *testing.T) { }) t.Run("All", func(t *testing.T) { - repository := NewRepository[*domain.Sparkle]() + repository := NewRepository[*domain.Sparkle](aggregator) require.NoError(t, repository.Save(t.Context(), &domain.Sparkle{ Sparklee: "@tanuki", Reason: "because", @@ -110,8 +130,7 @@ func TestInMemoryRepository(t *testing.T) { t.Run("Find", func(t *testing.T) { t.Run("when the entity exists", func(t *testing.T) { - sparkle, err := domain.NewSparkle("@tanuki for testing this func") - require.NoError(t, err) + sparkle := x.New[*domain.Sparkle](domain.WithText("@tanuki for testing this func")) require.NoError(t, storage.Save(t.Context(), sparkle)) result := storage.Find(t.Context(), sparkle.ID) diff --git a/app/domain/entity.go b/app/domain/entity.go index b2c2166..9cce1aa 100644 --- a/app/domain/entity.go +++ b/app/domain/entity.go @@ -1,12 +1,31 @@ package domain -import "github.com/xlgmokha/x/pkg/x" +import ( + "errors" +) type Entity interface { Identifiable Validate() error } -func New[T Entity](options ...x.Configure[T]) T { - return x.New[T](x.Map[x.Configure[T], x.Option[T]](options, x.With[T])...) +type entity struct { + ID ID `json:"id" jsonapi:"primary,entities"` +} + +func (s *entity) GetID() ID { + return s.ID +} + +func (s *entity) SetID(id ID) error { + s.ID = id + return nil +} + +func (s *entity) Validate() error { + return errors.New("method Validate not implemented") +} + +func (s *entity) ToGID() GlobalID { + return GlobalID("gid://sparkle/Entity/" + s.ID.String()) } diff --git a/app/domain/entity_test.go b/app/domain/entity_test.go new file mode 100644 index 0000000..1ac1d26 --- /dev/null +++ b/app/domain/entity_test.go @@ -0,0 +1,40 @@ +package domain + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xlgmokha/x/pkg/x" +) + +func TestEntity(t *testing.T) { + type example struct { + entity + } + + t.Run("GetID", func(t *testing.T) { + t.Run("returns the assigned ID", func(t *testing.T) { + example := x.New[*example](WithULID[*example]()) + + assert.Equal(t, example.ID, example.GetID()) + }) + }) + + t.Run("SetID", func(t *testing.T) { + t.Run("assigns a new ID", func(t *testing.T) { + id := ID("1") + example := x.New[*example]() + example.SetID(id) + + assert.Equal(t, id, example.GetID()) + }) + }) + + t.Run("ToGID", func(t *testing.T) { + example := x.New[*example](WithULID[*example]()) + gid := example.ToGID() + + assert.Equal(t, fmt.Sprintf("gid://sparkle/Entity/%s", example.ID), gid.String()) + }) +} diff --git a/app/domain/global_id.go b/app/domain/global_id.go new file mode 100644 index 0000000..de0093d --- /dev/null +++ b/app/domain/global_id.go @@ -0,0 +1,31 @@ +package domain + +import ( + "net/url" + "strings" + + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" +) + +type GlobalID string + +func (self GlobalID) String() string { + return string(self) +} + +func (self GlobalID) ToObjectReference() *v1.ObjectReference { + url, err := url.Parse(self.String()) + if err != nil { + return &v1.ObjectReference{} + } + + segments := strings.SplitN(url.Path, "/", 3) + if len(segments) < 3 { + return &v1.ObjectReference{} + } + + return &v1.ObjectReference{ + ObjectType: strings.ToLower(segments[1]), + ObjectId: segments[2], + } +} diff --git a/app/domain/global_id_test.go b/app/domain/global_id_test.go new file mode 100644 index 0000000..e678e6c --- /dev/null +++ b/app/domain/global_id_test.go @@ -0,0 +1,44 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGlobalID(t *testing.T) { + + t.Run("ToObjectReference", func(t *testing.T) { + tt := []struct { + input string + id string + oType string + isValid bool + }{ + {"gid://app/Sparkle/1", "1", "sparkle", true}, + {"gid://example/sparkle/1", "1", "sparkle", true}, + {"gid://example/sparkle/696266fc-68df-11f0-bbc2-7ec11f4b308c", "696266fc-68df-11f0-bbc2-7ec11f4b308c", "sparkle", true}, + {"gid://example/User/tanuki", "tanuki", "user", true}, + {"", "", "", false}, + {"gid://example", "", "", false}, + {"gid://example/Example", "", "", false}, + } + + for _, example := range tt { + t.Run(example.input, func(t *testing.T) { + reference := GlobalID(example.input).ToObjectReference() + + require.NotNil(t, reference) + assert.Equal(t, example.id, reference.GetObjectId()) + assert.Equal(t, example.oType, reference.GetObjectType()) + + if example.isValid { + require.NoError(t, reference.Validate()) + } else { + require.Error(t, reference.Validate()) + } + }) + } + }) +} diff --git a/app/domain/identifiable.go b/app/domain/identifiable.go index 06bec07..005c98c 100644 --- a/app/domain/identifiable.go +++ b/app/domain/identifiable.go @@ -1,15 +1,22 @@ package domain -import "github.com/xlgmokha/x/pkg/x" +import ( + "github.com/xlgmokha/x/pkg/x" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" +) type Identifiable interface { GetID() ID SetID(id ID) error - ToGID() string + ToGID() GlobalID } -func WithID[T Identifiable](id ID) x.Configure[T] { - return func(item T) { +func WithID[T Identifiable](id ID) x.Option[T] { + return x.With(func(item T) { item.SetID(id) - } + }) +} + +func WithULID[T Identifiable]() x.Option[T] { + return WithID[T](ID(pls.GenerateULID())) } diff --git a/app/domain/permission.go b/app/domain/permission.go new file mode 100644 index 0000000..c7a5ffe --- /dev/null +++ b/app/domain/permission.go @@ -0,0 +1,21 @@ +package domain + +import ( + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" +) + +type Permission string + +func (p Permission) RequestFor(user Identifiable, resource Identifiable) *v1.CheckPermissionRequest { + return &v1.CheckPermissionRequest{ + Subject: &v1.SubjectReference{ + Object: user.ToGID().ToObjectReference(), + }, + Permission: p.String(), + Resource: resource.ToGID().ToObjectReference(), + } +} + +func (p Permission) String() string { + return string(p) +} diff --git a/app/domain/sparkle.go b/app/domain/sparkle.go index d4f70b2..5ed73df 100644 --- a/app/domain/sparkle.go +++ b/app/domain/sparkle.go @@ -4,14 +4,14 @@ import ( "errors" "regexp" - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" + "github.com/xlgmokha/x/pkg/x" ) type Sparkle struct { - ID ID `json:"id" jsonapi:"primary,sparkles"` Sparklee string `json:"sparklee" jsonapi:"attr,sparklee"` Author *User `json:"author" jsonapi:"attr,author"` Reason string `json:"reason" jsonapi:"attr,reason"` + entity } var SparkleRegex = regexp.MustCompile(`\A\s*(?P<sparklee>@\w+)\s+(?P<reason>.+)\z`) @@ -19,45 +19,33 @@ var SparkleeIndex = SparkleRegex.SubexpIndex("sparklee") var ReasonIndex = SparkleRegex.SubexpIndex("reason") var ReasonIsRequired = errors.New("Reason is required") -var SparkleIsEmpty = errors.New("Sparkle is empty") -var SparkleIsInvalid = errors.New("Sparkle is invalid") var SparkleeIsRequired = errors.New("Sparklee is required") -func NewSparkle(text string) (*Sparkle, error) { - if len(text) == 0 { - return nil, SparkleIsEmpty - } - - matches := SparkleRegex.FindStringSubmatch(text) - if len(matches) == 0 { - return nil, SparkleIsInvalid - } +func WithText(text string) x.Option[*Sparkle] { + return x.With(func(item *Sparkle) { + if len(text) == 0 { + return + } - return &Sparkle{ - ID: ID(pls.GenerateULID()), - Sparklee: matches[SparkleeIndex], - Reason: matches[ReasonIndex], - }, nil -} + matches := SparkleRegex.FindStringSubmatch(text) + if len(matches) == 0 { + return + } -func (s *Sparkle) GetID() ID { - return s.ID -} - -func (s *Sparkle) SetID(id ID) error { - s.ID = id - return nil + item.Sparklee = matches[SparkleeIndex] + item.Reason = matches[ReasonIndex] + }) } -func (s *Sparkle) ToGID() string { - return "gid://sparkle/Sparkle/" + s.ID.String() +func (s *Sparkle) ToGID() GlobalID { + return GlobalID("gid://sparkle/Sparkle/" + s.ID.String()) } func (s *Sparkle) Validate() error { - if s.Sparklee == "" { + if x.IsZero(s.Sparklee) { return SparkleeIsRequired } - if s.Reason == "" { + if x.IsZero(s.Reason) { return ReasonIsRequired } return nil diff --git a/app/domain/sparkle_test.go b/app/domain/sparkle_test.go index 8d81afd..fcaf2f2 100644 --- a/app/domain/sparkle_test.go +++ b/app/domain/sparkle_test.go @@ -1,51 +1,61 @@ package domain import ( + "fmt" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xlgmokha/x/pkg/x" ) func TestSparkle(t *testing.T) { - t.Run("NewSparkle", func(t *testing.T) { + t.Run("New", func(t *testing.T) { t.Run("with a valid body", func(t *testing.T) { - sparkle, err := NewSparkle("@tanuki for helping me with my homework!") + sparkle := x.New[*Sparkle](WithText("@tanuki for helping me with my homework!")) - assert.Nil(t, err) - if err != nil { - assert.Equal(t, "@tanuki", sparkle.Sparklee) - assert.Equal(t, "for helping me with my homework!", sparkle.Reason) - } + require.NoError(t, sparkle.Validate()) + assert.Equal(t, "@tanuki", sparkle.Sparklee) + assert.Equal(t, "for helping me with my homework!", sparkle.Reason) }) t.Run("with an empty body", func(t *testing.T) { - sparkle, err := NewSparkle("") + sparkle := x.New[*Sparkle](WithText("")) - assert.Nil(t, sparkle) - assert.NotNil(t, err) - if err != nil { - assert.Equal(t, "Sparkle is empty", err.Error()) - } + require.Error(t, sparkle.Validate()) }) t.Run("without a reason", func(t *testing.T) { - sparkle, err := NewSparkle("@tanuki") + sparkle := x.New[*Sparkle](WithText("@tanuki")) - assert.Nil(t, sparkle) - assert.NotNil(t, err) - if err != nil { - assert.Equal(t, "Sparkle is invalid", err.Error()) - } + require.Error(t, sparkle.Validate()) }) t.Run("without a username", func(t *testing.T) { - sparkle, err := NewSparkle("for helping me with my homework") + sparkle := x.New[*Sparkle](WithText("for helping me with my homework")) - assert.Nil(t, sparkle) - assert.NotNil(t, err) - if err != nil { - assert.Equal(t, "Sparkle is invalid", err.Error()) - } + require.Error(t, sparkle.Validate()) + }) + }) + + t.Run("ToGID", func(t *testing.T) { + t.Run("returns a valid global id", func(t *testing.T) { + sparkle := x.New[*Sparkle](WithULID[*Sparkle]()) + gid := sparkle.ToGID() + + assert.Equal(t, fmt.Sprintf("gid://sparkle/Sparkle/%s", sparkle.ID), gid.String()) + }) + }) + + t.Run("ToObjectReference", func(t *testing.T) { + t.Run("returns a valid object reference", func(t *testing.T) { + sparkle := x.New[*Sparkle](WithULID[*Sparkle]()) + reference := sparkle.ToGID().ToObjectReference() + + require.NotNil(t, reference) + require.NoError(t, reference.ValidateAll()) + assert.Equal(t, sparkle.ID.String(), reference.GetObjectId()) + assert.Equal(t, "sparkle", reference.GetObjectType()) }) }) } diff --git a/app/domain/user.go b/app/domain/user.go index 52cd780..198fafc 100644 --- a/app/domain/user.go +++ b/app/domain/user.go @@ -1,6 +1,8 @@ package domain -import "github.com/xlgmokha/x/pkg/x" +import ( + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" +) type User struct { ID ID `json:"id" jsonapi:"primary,users"` @@ -9,10 +11,6 @@ type User struct { Picture string `json:"picture" jsonapi:"attr,picture"` } -func NewUser(options ...x.Configure[*User]) *User { - return New[*User](options...) -} - func (u *User) GetID() ID { return u.ID } @@ -34,6 +32,12 @@ func (self *User) Sparkle(sparklee string, reason string) *Sparkle { } } -func (self *User) ToGID() string { - return "gid://sparkle/User/" + self.ID.String() +func (self *User) ToGID() GlobalID { + return GlobalID("gid://sparkle/User/" + self.Username) +} + +func (self *User) ToSubjectReference() *v1.SubjectReference { + return &v1.SubjectReference{ + Object: self.ToGID().ToObjectReference(), + } } diff --git a/app/init.go b/app/init.go index 5057fe4..ea67e48 100644 --- a/app/init.go +++ b/app/init.go @@ -1,53 +1,75 @@ package app import ( + "context" "net/http" "os" + "github.com/authzed/authzed-go/v1" "github.com/rs/zerolog" "github.com/xlgmokha/x/pkg/env" + "github.com/xlgmokha/x/pkg/event" "github.com/xlgmokha/x/pkg/ioc" "github.com/xlgmokha/x/pkg/log" "github.com/xlgmokha/x/pkg/mapper" - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/rpc" + "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/controllers/dashboard" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/controllers/sparkles" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/db" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/jobs" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/authz" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/web" ) func init() { - ioc.RegisterSingleton[*zerolog.Logger](ioc.Default, func() *zerolog.Logger { + c := ioc.Default + + ioc.RegisterSingleton[*zerolog.Logger](c, func() *zerolog.Logger { return log.New(os.Stdout, log.Fields{"app": "sparkled"}) }) - ioc.RegisterSingleton[domain.Repository[*domain.Sparkle]](ioc.Default, func() domain.Repository[*domain.Sparkle] { - return db.NewRepository[*domain.Sparkle]() + ioc.RegisterSingleton[*authzed.Client](c, func() *authzed.Client { + return authz.NewSpiceDBClient( + context.Background(), + env.Fetch("ZED_ENDPOINT", ":50051"), + env.Fetch("ZED_TOKEN", "secret"), + ) + }) + ioc.Register[authz.CheckPermissionService](c, func() authz.CheckPermissionService { + return ioc.MustResolve[*authzed.Client](c) + }) + ioc.RegisterSingleton[*event.Aggregator](c, func() *event.Aggregator { + return x.New[*event.Aggregator](event.WithDefaults()) + }) + ioc.Register[*event.TypedAggregator[*domain.Sparkle]](c, func() *event.TypedAggregator[*domain.Sparkle] { + return x.New[*event.TypedAggregator[*domain.Sparkle]](event.WithAggregator[*domain.Sparkle]( + ioc.MustResolve[*event.Aggregator](c), + )) + }) + ioc.RegisterSingleton[domain.Repository[*domain.Sparkle]](c, func() domain.Repository[*domain.Sparkle] { + return db.NewRepository[*domain.Sparkle](ioc.MustResolve[*event.TypedAggregator[*domain.Sparkle]](c)) }) - ioc.RegisterSingleton[*http.ServeMux](ioc.Default, func() *http.ServeMux { + ioc.RegisterSingleton[*http.ServeMux](c, func() *http.ServeMux { return http.NewServeMux() }) - ioc.Register[*dashboard.Controller](ioc.Default, func() *dashboard.Controller { + ioc.Register[*dashboard.Controller](c, func() *dashboard.Controller { return dashboard.New() }) - ioc.Register[*sparkles.Controller](ioc.Default, func() *sparkles.Controller { - return sparkles.New(ioc.MustResolve[domain.Repository[*domain.Sparkle]](ioc.Default)) + ioc.Register[*sparkles.Controller](c, func() *sparkles.Controller { + return sparkles.New( + ioc.MustResolve[domain.Repository[*domain.Sparkle]](c), + ioc.MustResolve[authz.CheckPermissionService](c), + ) }) - ioc.RegisterSingleton[*http.Client](ioc.Default, func() *http.Client { + ioc.RegisterSingleton[*http.Client](c, func() *http.Client { return &http.Client{ Transport: &web.Transport{ - Logger: ioc.MustResolve[*zerolog.Logger](ioc.Default), + Logger: ioc.MustResolve[*zerolog.Logger](c), }, } }) - ioc.Register[rpc.Ability](ioc.Default, func() rpc.Ability { - return rpc.NewAbilityProtobufClient( - env.Fetch("AUTHZD_HOST", ""), - ioc.MustResolve[*http.Client](ioc.Default), - ) - }) - http.DefaultClient = ioc.MustResolve[*http.Client](ioc.Default) + http.DefaultClient = ioc.MustResolve[*http.Client](c) mapper.Register[*http.Request, log.Fields](func(r *http.Request) log.Fields { return log.Fields{ @@ -58,4 +80,12 @@ func init() { "request_id": r.Header.Get("x-request-id"), } }) + + logger := ioc.MustResolve[*zerolog.Logger](c) + ctx := logger.WithContext(context.Background()) + client := ioc.MustResolve[*authzed.Client](c) + + ioc. + MustResolve[*event.TypedAggregator[*domain.Sparkle]](c). + SubscribeTo("after.create", jobs.NewCreateSparkle(ctx, client).Run) } diff --git a/app/jobs/create_sparkle.go b/app/jobs/create_sparkle.go new file mode 100644 index 0000000..3a03b1f --- /dev/null +++ b/app/jobs/create_sparkle.go @@ -0,0 +1,75 @@ +package jobs + +import ( + "context" + "strings" + + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/authzed/authzed-go/v1" + "github.com/containerd/log" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" +) + +type CreateSparkle struct { + client *authzed.Client + ctx context.Context +} + +func NewCreateSparkle(ctx context.Context, client *authzed.Client) Job[*domain.Sparkle] { + return &CreateSparkle{ + client: client, + ctx: ctx, + } +} + +func (job *CreateSparkle) Run(item *domain.Sparkle) { + response, err := job.client.WriteRelationships(job.ctx, job.requestFor(item)) + if err != nil { + pls.LogErrorNow(job.ctx, err) + } + pls.LogNow(job.ctx, log.Fields{"response": response}) +} + +func (job *CreateSparkle) requestFor(sparkle *domain.Sparkle) *v1.WriteRelationshipsRequest { + resource := sparkle.ToGID().ToObjectReference() + + return &v1.WriteRelationshipsRequest{ + Updates: []*v1.RelationshipUpdate{ + &v1.RelationshipUpdate{ + Operation: v1.RelationshipUpdate_OPERATION_CREATE, + Relationship: &v1.Relationship{ + Resource: resource, + Relation: "sparkler", + Subject: sparkle.Author.ToSubjectReference(), + }, + }, + &v1.RelationshipUpdate{ + Operation: v1.RelationshipUpdate_OPERATION_CREATE, + Relationship: &v1.Relationship{ + Resource: resource, + Relation: "sparklee", + Subject: &v1.SubjectReference{ + Object: &v1.ObjectReference{ + ObjectType: "user", + ObjectId: strings.TrimPrefix(sparkle.Sparklee, "@"), + }, + }, + }, + }, + &v1.RelationshipUpdate{ + Operation: v1.RelationshipUpdate_OPERATION_CREATE, + Relationship: &v1.Relationship{ + Resource: resource, + Relation: "reader", + Subject: &v1.SubjectReference{ + Object: &v1.ObjectReference{ + ObjectType: "user", + ObjectId: "*", + }, + }, + }, + }, + }, + } +} diff --git a/app/jobs/job.go b/app/jobs/job.go new file mode 100644 index 0000000..3864c76 --- /dev/null +++ b/app/jobs/job.go @@ -0,0 +1,5 @@ +package jobs + +type Job[T any] interface { + Run(T) +} diff --git a/app/middleware/permission.go b/app/middleware/permission.go deleted file mode 100644 index 03e7cf9..0000000 --- a/app/middleware/permission.go +++ /dev/null @@ -1,24 +0,0 @@ -package middleware - -import ( - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/rpc" - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" -) - -type Permission string - -func (p Permission) ToGID() string { - return "gid://sparkle/Permission/" + p.String() -} - -func (p Permission) RequestFor(user domain.Identifiable, resource domain.Identifiable) *rpc.AllowRequest { - return &rpc.AllowRequest{ - Subject: user.ToGID(), - Permission: p.ToGID(), - Resource: resource.ToGID(), - } -} - -func (p Permission) String() string { - return string(p) -} diff --git a/app/middleware/require_permission.go b/app/middleware/require_permission.go index 563278e..91e2b72 100644 --- a/app/middleware/require_permission.go +++ b/app/middleware/require_permission.go @@ -3,27 +3,30 @@ package middleware import ( "net/http" - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/rpc" + v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/xlgmokha/x/pkg/x" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/cfg" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/authz" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" ) -func RequirePermission(permission Permission, ability rpc.Ability) func(http.Handler) http.Handler { +func RequirePermission(permission domain.Permission, client authz.CheckPermissionService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := cfg.CurrentUser.From(r.Context()) - reply, err := ability.Allowed(r.Context(), - permission.RequestFor(user, &domain.Sparkle{ID: "*"}), - ) + reply, err := client.CheckPermission(r.Context(), permission.RequestFor( + user, + x.New[*domain.Sparkle](domain.WithID[*domain.Sparkle](domain.ID("*"))), + )) if err != nil { pls.LogError(r.Context(), err) w.WriteHeader(http.StatusForbidden) return } - if reply.Result { + if reply.Permissionship == v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION { next.ServeHTTP(w, r) } else { w.WriteHeader(http.StatusForbidden) diff --git a/app/middleware/require_permission_test.go b/app/middleware/require_permission_test.go index 34a04a7..a936e1e 100644 --- a/app/middleware/require_permission_test.go +++ b/app/middleware/require_permission_test.go @@ -1,38 +1,25 @@ package middleware import ( - "context" "net/http" "testing" "github.com/stretchr/testify/require" "github.com/xlgmokha/x/pkg/test" - "gitlab.com/gitlab-org/software-supply-chain-security/authorization/authzd.git/pkg/rpc" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/cfg" "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/app/domain" + "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/internal/stub" ) -type MockAbility func(context.Context, *rpc.AllowRequest) (*rpc.AllowReply, error) - -func (m MockAbility) Allowed(ctx context.Context, r *rpc.AllowRequest) (*rpc.AllowReply, error) { - return m(ctx, r) -} - func TestRequirePermission(t *testing.T) { - user := &domain.User{ID: domain.ID("1")} + user := &domain.User{ID: domain.ID("1"), Username: "tanuki"} ctx := cfg.CurrentUser.With(t.Context(), user) - permission := Permission("read_sparkles") + permission := domain.Permission("read") t.Run("when the permission is granted", func(t *testing.T) { r, w := test.RequestResponse("GET", "/sparkles", test.WithContext(ctx)) - middleware := RequirePermission(permission, MockAbility(func(ctx context.Context, r *rpc.AllowRequest) (*rpc.AllowReply, error) { - require.Equal(t, "gid://sparkle/User/"+user.ID.String(), r.Subject) - require.Equal(t, permission.ToGID(), r.Permission) - require.Equal(t, "gid://sparkle/Sparkle/*", r.Resource) - - return &rpc.AllowReply{Result: true}, nil - })) + middleware := RequirePermission(permission, stub.AllowWith(t, "user:tanuki", permission.String(), "sparkle:*")) server := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })) @@ -44,9 +31,7 @@ func TestRequirePermission(t *testing.T) { t.Run("when the permission is denied", func(t *testing.T) { r, w := test.RequestResponse("GET", "/sparkles", test.WithContext(ctx)) - middleware := RequirePermission(permission, MockAbility(func(ctx context.Context, r *rpc.AllowRequest) (*rpc.AllowReply, error) { - return &rpc.AllowReply{Result: false}, nil - })) + middleware := RequirePermission(permission, stub.Deny()) server := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Fail(t, "unexpected call to handler") })) |
