summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-07-24 17:58:01 -0600
committermo khan <mo@mokhan.ca>2025-07-24 17:58:01 -0600
commit72296119fc9755774719f8f625ad03e0e0ec457a (patch)
treeed236ddee12a20fb55b7cfecf13f62d3a000dcb5 /app
parenta920a8cfe415858bb2777371a77018599ffed23f (diff)
parenteaa1bd3b8e12934aed06413d75e7482ac58d805a (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.go13
-rw-r--r--app/controllers/sparkles/controller_test.go118
-rw-r--r--app/db/in_memory_repository.go14
-rw-r--r--app/db/in_memory_repository_test.go29
-rw-r--r--app/domain/entity.go25
-rw-r--r--app/domain/entity_test.go40
-rw-r--r--app/domain/global_id.go31
-rw-r--r--app/domain/global_id_test.go44
-rw-r--r--app/domain/identifiable.go17
-rw-r--r--app/domain/permission.go21
-rw-r--r--app/domain/sparkle.go48
-rw-r--r--app/domain/sparkle_test.go60
-rw-r--r--app/domain/user.go18
-rw-r--r--app/init.go64
-rw-r--r--app/jobs/create_sparkle.go75
-rw-r--r--app/jobs/job.go5
-rw-r--r--app/middleware/permission.go24
-rw-r--r--app/middleware/require_permission.go15
-rw-r--r--app/middleware/require_permission_test.go25
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")
}))