From d2ebd0a9afed57ba11f053266e6ae1edb84a0f36 Mon Sep 17 00:00:00 2001 From: mo khan Date: Wed, 23 Jul 2025 12:40:12 -0600 Subject: feat: authorize requests to create sparkles --- app/controllers/sparkles/controller.go | 12 ++++-- app/controllers/sparkles/controller_test.go | 58 +++++++++++++++++++---------- app/domain/permission.go | 21 +++++++++++ app/init.go | 4 +- app/middleware/permission.go | 26 ------------- app/middleware/require_permission.go | 2 +- app/middleware/require_permission_test.go | 2 +- internal/stub/check.go | 6 +-- pkg/authz/spice.go | 2 +- 9 files changed, 75 insertions(+), 58 deletions(-) create mode 100644 app/domain/permission.go delete mode 100644 app/middleware/permission.go diff --git a/app/controllers/sparkles/controller.go b/app/controllers/sparkles/controller.go index 80e95cb..ef2ecd5 100644 --- a/app/controllers/sparkles/controller.go +++ b/app/controllers/sparkles/controller.go @@ -14,11 +14,15 @@ import ( ) type Controller struct { - db domain.Repository[*domain.Sparkle] + db domain.Repository[*domain.Sparkle] + check authz.CheckPermissionService } -func New(db domain.Repository[*domain.Sparkle], check authz.PermissionService) *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) { @@ -26,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", x.Try(ioc.Resolve[authz.CheckPermission](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 006c3fa..64b4dc5 100644 --- a/app/controllers/sparkles/controller_test.go +++ b/app/controllers/sparkles/controller_test.go @@ -62,16 +62,16 @@ 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) { + t.Run("when a user is authenticated", func(t *testing.T) { currentUser := domain.NewUser(domain.WithID[*domain.User](domain.ID("1"))) + repository := db.NewRepository[*domain.Sparkle]() - t.Run("when the user is authorized to create sparkles", func(t *testing.T) { - t.Run("saves a new sparkle", func(t *testing.T) { - repository := db.NewRepository[*domain.Sparkle]() - mux := http.NewServeMux() - controller := New(repository, stub.AllowWith(t, "user:1", "create", "sparkle:*")) - controller.MountTo(mux) + t.Run("when the user is authorized", func(t *testing.T) { + mux := http.NewServeMux() + controller := New(repository, stub.AllowWith(t, "user:1", "create", "sparkle:*")) + controller.MountTo(mux) + t.Run("saves a new sparkle", func(t *testing.T) { sparkle, _ := domain.NewSparkle("@tanuki for reviewing my code!") request, response := test.RequestResponse( "POST", @@ -103,24 +103,42 @@ func TestSparkles(t *testing.T) { assert.Equal(t, currentUser, item.Author) }) }) + + t.Run("prevents double WriteHeader when serialization fails", func(t *testing.T) { + currentUser := domain.NewUser(domain.WithID[*domain.User](domain.ID("1"))) + sparkle, _ := domain.NewSparkle("@user for testing") + + 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, stub.Allow()) + t.Run("when the user is not authorized", func(t *testing.T) { + 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, _ := 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), + ) - 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/domain/permission.go b/app/domain/permission.go new file mode 100644 index 0000000..53d4754 --- /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.ToObjectReference(), + }, + Permission: p.String(), + Resource: resource.ToObjectReference(), + } +} + +func (p Permission) String() string { + return string(p) +} diff --git a/app/init.go b/app/init.go index 2986809..960102a 100644 --- a/app/init.go +++ b/app/init.go @@ -30,7 +30,7 @@ func init() { env.Fetch("ZED_TOKEN", "secret"), ) }) - ioc.Register[authz.PermissionService](ioc.Default, func() authz.PermissionService { + ioc.Register[authz.CheckPermissionService](ioc.Default, func() authz.CheckPermissionService { return ioc.MustResolve[*authzed.Client](ioc.Default) }) ioc.RegisterSingleton[domain.Repository[*domain.Sparkle]](ioc.Default, func() domain.Repository[*domain.Sparkle] { @@ -45,7 +45,7 @@ func init() { ioc.Register[*sparkles.Controller](ioc.Default, func() *sparkles.Controller { return sparkles.New( ioc.MustResolve[domain.Repository[*domain.Sparkle]](ioc.Default), - ioc.MustResolve[authz.PermissionService](ioc.Default), + ioc.MustResolve[authz.CheckPermissionService](ioc.Default), ) }) ioc.RegisterSingleton[*http.Client](ioc.Default, func() *http.Client { diff --git a/app/middleware/permission.go b/app/middleware/permission.go deleted file mode 100644 index 36a7ea0..0000000 --- a/app/middleware/permission.go +++ /dev/null @@ -1,26 +0,0 @@ -package middleware - -import ( - v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" - "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) *v1.CheckPermissionRequest { - return &v1.CheckPermissionRequest{ - Subject: &v1.SubjectReference{ - Object: user.ToObjectReference(), - }, - Permission: p.String(), - Resource: resource.ToObjectReference(), - } -} - -func (p Permission) String() string { - return string(p) -} diff --git a/app/middleware/require_permission.go b/app/middleware/require_permission.go index cfcae0c..441b334 100644 --- a/app/middleware/require_permission.go +++ b/app/middleware/require_permission.go @@ -10,7 +10,7 @@ import ( "gitlab.com/gitlab-org/software-supply-chain-security/authorization/sparkled/pkg/pls" ) -func RequirePermission(permission Permission, client authz.PermissionService) 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()) diff --git a/app/middleware/require_permission_test.go b/app/middleware/require_permission_test.go index b11a33c..2023345 100644 --- a/app/middleware/require_permission_test.go +++ b/app/middleware/require_permission_test.go @@ -14,7 +14,7 @@ import ( func TestRequirePermission(t *testing.T) { user := &domain.User{ID: domain.ID("1")} ctx := cfg.CurrentUser.With(t.Context(), user) - permission := Permission("read") + permission := domain.Permission("read") t.Run("when the permission is granted", func(t *testing.T) { r, w := test.RequestResponse("GET", "/sparkles", test.WithContext(ctx)) diff --git a/internal/stub/check.go b/internal/stub/check.go index ec257e3..073b35b 100644 --- a/internal/stub/check.go +++ b/internal/stub/check.go @@ -17,7 +17,7 @@ func (m Check) CheckPermission(ctx context.Context, r *v1.CheckPermissionRequest return m(ctx, r) } -func AllowWith(t *testing.T, subject string, permission string, resource string) authz.PermissionService { +func AllowWith(t *testing.T, subject string, permission string, resource string) authz.CheckPermissionService { user := strings.SplitN(subject, ":", 2) model := strings.SplitN(resource, ":", 2) @@ -36,7 +36,7 @@ func AllowWith(t *testing.T, subject string, permission string, resource string) }) } -func Allow() authz.PermissionService { +func Allow() authz.CheckPermissionService { return Check(func(ctx context.Context, r *v1.CheckPermissionRequest) (*v1.CheckPermissionResponse, error) { return &v1.CheckPermissionResponse{ Permissionship: v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, @@ -44,7 +44,7 @@ func Allow() authz.PermissionService { }) } -func Deny() authz.PermissionService { +func Deny() authz.CheckPermissionService { return Check(func(ctx context.Context, r *v1.CheckPermissionRequest) (*v1.CheckPermissionResponse, error) { return &v1.CheckPermissionResponse{ Permissionship: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, diff --git a/pkg/authz/spice.go b/pkg/authz/spice.go index 47468d9..cd534a1 100644 --- a/pkg/authz/spice.go +++ b/pkg/authz/spice.go @@ -21,6 +21,6 @@ func NewSpiceDBClient(ctx context.Context, host string, presharedKey string) *au return client } -type PermissionService interface { +type CheckPermissionService interface { CheckPermission(ctx context.Context, in *v1.CheckPermissionRequest, opts ...grpc.CallOption) (*v1.CheckPermissionResponse, error) } -- cgit v1.2.3