diff --git a/cmd/serve.go b/cmd/serve.go index 5c0dee646..d86137756 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -473,6 +473,7 @@ func buildAPIDependencies( authnService, projectService, organizationService, + userPATService, ) invitationService := invitation.NewService(mailDialer, postgres.NewInvitationRepository(logger, dbc), diff --git a/core/authenticate/mocks/authenticator_func.go b/core/authenticate/mocks/authenticator_func.go new file mode 100644 index 000000000..837935b78 --- /dev/null +++ b/core/authenticate/mocks/authenticator_func.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + authenticate "github.com/raystack/frontier/core/authenticate" + + mock "github.com/stretchr/testify/mock" +) + +// AuthenticatorFunc is an autogenerated mock type for the AuthenticatorFunc type +type AuthenticatorFunc struct { + mock.Mock +} + +type AuthenticatorFunc_Expecter struct { + mock *mock.Mock +} + +func (_m *AuthenticatorFunc) EXPECT() *AuthenticatorFunc_Expecter { + return &AuthenticatorFunc_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function with given fields: ctx, s +func (_m *AuthenticatorFunc) Execute(ctx context.Context, s *authenticate.Service) (authenticate.Principal, error) { + ret := _m.Called(ctx, s) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 authenticate.Principal + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *authenticate.Service) (authenticate.Principal, error)); ok { + return rf(ctx, s) + } + if rf, ok := ret.Get(0).(func(context.Context, *authenticate.Service) authenticate.Principal); ok { + r0 = rf(ctx, s) + } else { + r0 = ret.Get(0).(authenticate.Principal) + } + + if rf, ok := ret.Get(1).(func(context.Context, *authenticate.Service) error); ok { + r1 = rf(ctx, s) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AuthenticatorFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type AuthenticatorFunc_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - ctx context.Context +// - s *authenticate.Service +func (_e *AuthenticatorFunc_Expecter) Execute(ctx interface{}, s interface{}) *AuthenticatorFunc_Execute_Call { + return &AuthenticatorFunc_Execute_Call{Call: _e.mock.On("Execute", ctx, s)} +} + +func (_c *AuthenticatorFunc_Execute_Call) Run(run func(ctx context.Context, s *authenticate.Service)) *AuthenticatorFunc_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*authenticate.Service)) + }) + return _c +} + +func (_c *AuthenticatorFunc_Execute_Call) Return(_a0 authenticate.Principal, _a1 error) *AuthenticatorFunc_Execute_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuthenticatorFunc_Execute_Call) RunAndReturn(run func(context.Context, *authenticate.Service) (authenticate.Principal, error)) *AuthenticatorFunc_Execute_Call { + _c.Call.Return(run) + return _c +} + +// NewAuthenticatorFunc creates a new instance of AuthenticatorFunc. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthenticatorFunc(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthenticatorFunc { + mock := &AuthenticatorFunc{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/authn_service.go b/core/resource/mocks/authn_service.go new file mode 100644 index 000000000..7d0536f98 --- /dev/null +++ b/core/resource/mocks/authn_service.go @@ -0,0 +1,109 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + authenticate "github.com/raystack/frontier/core/authenticate" + + mock "github.com/stretchr/testify/mock" +) + +// AuthnService is an autogenerated mock type for the AuthnService type +type AuthnService struct { + mock.Mock +} + +type AuthnService_Expecter struct { + mock *mock.Mock +} + +func (_m *AuthnService) EXPECT() *AuthnService_Expecter { + return &AuthnService_Expecter{mock: &_m.Mock} +} + +// GetPrincipal provides a mock function with given fields: ctx, via +func (_m *AuthnService) GetPrincipal(ctx context.Context, via ...authenticate.ClientAssertion) (authenticate.Principal, error) { + _va := make([]interface{}, len(via)) + for _i := range via { + _va[_i] = via[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetPrincipal") + } + + var r0 authenticate.Principal + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, ...authenticate.ClientAssertion) (authenticate.Principal, error)); ok { + return rf(ctx, via...) + } + if rf, ok := ret.Get(0).(func(context.Context, ...authenticate.ClientAssertion) authenticate.Principal); ok { + r0 = rf(ctx, via...) + } else { + r0 = ret.Get(0).(authenticate.Principal) + } + + if rf, ok := ret.Get(1).(func(context.Context, ...authenticate.ClientAssertion) error); ok { + r1 = rf(ctx, via...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AuthnService_GetPrincipal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPrincipal' +type AuthnService_GetPrincipal_Call struct { + *mock.Call +} + +// GetPrincipal is a helper method to define mock.On call +// - ctx context.Context +// - via ...authenticate.ClientAssertion +func (_e *AuthnService_Expecter) GetPrincipal(ctx interface{}, via ...interface{}) *AuthnService_GetPrincipal_Call { + return &AuthnService_GetPrincipal_Call{Call: _e.mock.On("GetPrincipal", + append([]interface{}{ctx}, via...)...)} +} + +func (_c *AuthnService_GetPrincipal_Call) Run(run func(ctx context.Context, via ...authenticate.ClientAssertion)) *AuthnService_GetPrincipal_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]authenticate.ClientAssertion, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(authenticate.ClientAssertion) + } + } + run(args[0].(context.Context), variadicArgs...) + }) + return _c +} + +func (_c *AuthnService_GetPrincipal_Call) Return(_a0 authenticate.Principal, _a1 error) *AuthnService_GetPrincipal_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuthnService_GetPrincipal_Call) RunAndReturn(run func(context.Context, ...authenticate.ClientAssertion) (authenticate.Principal, error)) *AuthnService_GetPrincipal_Call { + _c.Call.Return(run) + return _c +} + +// NewAuthnService creates a new instance of AuthnService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthnService(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthnService { + mock := &AuthnService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/config_repository.go b/core/resource/mocks/config_repository.go new file mode 100644 index 000000000..d7c2162f8 --- /dev/null +++ b/core/resource/mocks/config_repository.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + resource "github.com/raystack/frontier/core/resource" + mock "github.com/stretchr/testify/mock" +) + +// ConfigRepository is an autogenerated mock type for the ConfigRepository type +type ConfigRepository struct { + mock.Mock +} + +type ConfigRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *ConfigRepository) EXPECT() *ConfigRepository_Expecter { + return &ConfigRepository_Expecter{mock: &_m.Mock} +} + +// GetAll provides a mock function with given fields: ctx +func (_m *ConfigRepository) GetAll(ctx context.Context) ([]resource.YAML, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + + var r0 []resource.YAML + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]resource.YAML, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []resource.YAML); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]resource.YAML) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ConfigRepository_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' +type ConfigRepository_GetAll_Call struct { + *mock.Call +} + +// GetAll is a helper method to define mock.On call +// - ctx context.Context +func (_e *ConfigRepository_Expecter) GetAll(ctx interface{}) *ConfigRepository_GetAll_Call { + return &ConfigRepository_GetAll_Call{Call: _e.mock.On("GetAll", ctx)} +} + +func (_c *ConfigRepository_GetAll_Call) Run(run func(ctx context.Context)) *ConfigRepository_GetAll_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *ConfigRepository_GetAll_Call) Return(_a0 []resource.YAML, _a1 error) *ConfigRepository_GetAll_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ConfigRepository_GetAll_Call) RunAndReturn(run func(context.Context) ([]resource.YAML, error)) *ConfigRepository_GetAll_Call { + _c.Call.Return(run) + return _c +} + +// NewConfigRepository creates a new instance of ConfigRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewConfigRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *ConfigRepository { + mock := &ConfigRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/org_service.go b/core/resource/mocks/org_service.go new file mode 100644 index 000000000..ab3213749 --- /dev/null +++ b/core/resource/mocks/org_service.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + organization "github.com/raystack/frontier/core/organization" + mock "github.com/stretchr/testify/mock" +) + +// OrgService is an autogenerated mock type for the OrgService type +type OrgService struct { + mock.Mock +} + +type OrgService_Expecter struct { + mock *mock.Mock +} + +func (_m *OrgService) EXPECT() *OrgService_Expecter { + return &OrgService_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function with given fields: ctx, idOrName +func (_m *OrgService) Get(ctx context.Context, idOrName string) (organization.Organization, error) { + ret := _m.Called(ctx, idOrName) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 organization.Organization + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (organization.Organization, error)); ok { + return rf(ctx, idOrName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) organization.Organization); ok { + r0 = rf(ctx, idOrName) + } else { + r0 = ret.Get(0).(organization.Organization) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, idOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OrgService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type OrgService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - idOrName string +func (_e *OrgService_Expecter) Get(ctx interface{}, idOrName interface{}) *OrgService_Get_Call { + return &OrgService_Get_Call{Call: _e.mock.On("Get", ctx, idOrName)} +} + +func (_c *OrgService_Get_Call) Run(run func(ctx context.Context, idOrName string)) *OrgService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *OrgService_Get_Call) Return(_a0 organization.Organization, _a1 error) *OrgService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *OrgService_Get_Call) RunAndReturn(run func(context.Context, string) (organization.Organization, error)) *OrgService_Get_Call { + _c.Call.Return(run) + return _c +} + +// NewOrgService creates a new instance of OrgService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrgService(t interface { + mock.TestingT + Cleanup(func()) +}) *OrgService { + mock := &OrgService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/pat_service.go b/core/resource/mocks/pat_service.go new file mode 100644 index 000000000..72d530bf6 --- /dev/null +++ b/core/resource/mocks/pat_service.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/raystack/frontier/core/userpat/models" + mock "github.com/stretchr/testify/mock" +) + +// PATService is an autogenerated mock type for the PATService type +type PATService struct { + mock.Mock +} + +type PATService_Expecter struct { + mock *mock.Mock +} + +func (_m *PATService) EXPECT() *PATService_Expecter { + return &PATService_Expecter{mock: &_m.Mock} +} + +// GetByID provides a mock function with given fields: ctx, id +func (_m *PATService) GetByID(ctx context.Context, id string) (models.PAT, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetByID") + } + + var r0 models.PAT + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (models.PAT, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) models.PAT); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(models.PAT) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PATService_GetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByID' +type PATService_GetByID_Call struct { + *mock.Call +} + +// GetByID is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *PATService_Expecter) GetByID(ctx interface{}, id interface{}) *PATService_GetByID_Call { + return &PATService_GetByID_Call{Call: _e.mock.On("GetByID", ctx, id)} +} + +func (_c *PATService_GetByID_Call) Run(run func(ctx context.Context, id string)) *PATService_GetByID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *PATService_GetByID_Call) Return(_a0 models.PAT, _a1 error) *PATService_GetByID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PATService_GetByID_Call) RunAndReturn(run func(context.Context, string) (models.PAT, error)) *PATService_GetByID_Call { + _c.Call.Return(run) + return _c +} + +// NewPATService creates a new instance of PATService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPATService(t interface { + mock.TestingT + Cleanup(func()) +}) *PATService { + mock := &PATService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/project_service.go b/core/resource/mocks/project_service.go new file mode 100644 index 000000000..9fd776116 --- /dev/null +++ b/core/resource/mocks/project_service.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + project "github.com/raystack/frontier/core/project" + mock "github.com/stretchr/testify/mock" +) + +// ProjectService is an autogenerated mock type for the ProjectService type +type ProjectService struct { + mock.Mock +} + +type ProjectService_Expecter struct { + mock *mock.Mock +} + +func (_m *ProjectService) EXPECT() *ProjectService_Expecter { + return &ProjectService_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function with given fields: ctx, idOrName +func (_m *ProjectService) Get(ctx context.Context, idOrName string) (project.Project, error) { + ret := _m.Called(ctx, idOrName) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 project.Project + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (project.Project, error)); ok { + return rf(ctx, idOrName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) project.Project); ok { + r0 = rf(ctx, idOrName) + } else { + r0 = ret.Get(0).(project.Project) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, idOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ProjectService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type ProjectService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - idOrName string +func (_e *ProjectService_Expecter) Get(ctx interface{}, idOrName interface{}) *ProjectService_Get_Call { + return &ProjectService_Get_Call{Call: _e.mock.On("Get", ctx, idOrName)} +} + +func (_c *ProjectService_Get_Call) Run(run func(ctx context.Context, idOrName string)) *ProjectService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *ProjectService_Get_Call) Return(_a0 project.Project, _a1 error) *ProjectService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ProjectService_Get_Call) RunAndReturn(run func(context.Context, string) (project.Project, error)) *ProjectService_Get_Call { + _c.Call.Return(run) + return _c +} + +// NewProjectService creates a new instance of ProjectService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewProjectService(t interface { + mock.TestingT + Cleanup(func()) +}) *ProjectService { + mock := &ProjectService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/relation_service.go b/core/resource/mocks/relation_service.go new file mode 100644 index 000000000..507e8e644 --- /dev/null +++ b/core/resource/mocks/relation_service.go @@ -0,0 +1,257 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + relation "github.com/raystack/frontier/core/relation" + mock "github.com/stretchr/testify/mock" +) + +// RelationService is an autogenerated mock type for the RelationService type +type RelationService struct { + mock.Mock +} + +type RelationService_Expecter struct { + mock *mock.Mock +} + +func (_m *RelationService) EXPECT() *RelationService_Expecter { + return &RelationService_Expecter{mock: &_m.Mock} +} + +// BatchCheckPermission provides a mock function with given fields: ctx, relations +func (_m *RelationService) BatchCheckPermission(ctx context.Context, relations []relation.Relation) ([]relation.CheckPair, error) { + ret := _m.Called(ctx, relations) + + if len(ret) == 0 { + panic("no return value specified for BatchCheckPermission") + } + + var r0 []relation.CheckPair + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []relation.Relation) ([]relation.CheckPair, error)); ok { + return rf(ctx, relations) + } + if rf, ok := ret.Get(0).(func(context.Context, []relation.Relation) []relation.CheckPair); ok { + r0 = rf(ctx, relations) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]relation.CheckPair) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []relation.Relation) error); ok { + r1 = rf(ctx, relations) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RelationService_BatchCheckPermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BatchCheckPermission' +type RelationService_BatchCheckPermission_Call struct { + *mock.Call +} + +// BatchCheckPermission is a helper method to define mock.On call +// - ctx context.Context +// - relations []relation.Relation +func (_e *RelationService_Expecter) BatchCheckPermission(ctx interface{}, relations interface{}) *RelationService_BatchCheckPermission_Call { + return &RelationService_BatchCheckPermission_Call{Call: _e.mock.On("BatchCheckPermission", ctx, relations)} +} + +func (_c *RelationService_BatchCheckPermission_Call) Run(run func(ctx context.Context, relations []relation.Relation)) *RelationService_BatchCheckPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]relation.Relation)) + }) + return _c +} + +func (_c *RelationService_BatchCheckPermission_Call) Return(_a0 []relation.CheckPair, _a1 error) *RelationService_BatchCheckPermission_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RelationService_BatchCheckPermission_Call) RunAndReturn(run func(context.Context, []relation.Relation) ([]relation.CheckPair, error)) *RelationService_BatchCheckPermission_Call { + _c.Call.Return(run) + return _c +} + +// CheckPermission provides a mock function with given fields: ctx, rel +func (_m *RelationService) CheckPermission(ctx context.Context, rel relation.Relation) (bool, error) { + ret := _m.Called(ctx, rel) + + if len(ret) == 0 { + panic("no return value specified for CheckPermission") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, relation.Relation) (bool, error)); ok { + return rf(ctx, rel) + } + if rf, ok := ret.Get(0).(func(context.Context, relation.Relation) bool); ok { + r0 = rf(ctx, rel) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, relation.Relation) error); ok { + r1 = rf(ctx, rel) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RelationService_CheckPermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckPermission' +type RelationService_CheckPermission_Call struct { + *mock.Call +} + +// CheckPermission is a helper method to define mock.On call +// - ctx context.Context +// - rel relation.Relation +func (_e *RelationService_Expecter) CheckPermission(ctx interface{}, rel interface{}) *RelationService_CheckPermission_Call { + return &RelationService_CheckPermission_Call{Call: _e.mock.On("CheckPermission", ctx, rel)} +} + +func (_c *RelationService_CheckPermission_Call) Run(run func(ctx context.Context, rel relation.Relation)) *RelationService_CheckPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(relation.Relation)) + }) + return _c +} + +func (_c *RelationService_CheckPermission_Call) Return(_a0 bool, _a1 error) *RelationService_CheckPermission_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RelationService_CheckPermission_Call) RunAndReturn(run func(context.Context, relation.Relation) (bool, error)) *RelationService_CheckPermission_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx, rel +func (_m *RelationService) Create(ctx context.Context, rel relation.Relation) (relation.Relation, error) { + ret := _m.Called(ctx, rel) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 relation.Relation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, relation.Relation) (relation.Relation, error)); ok { + return rf(ctx, rel) + } + if rf, ok := ret.Get(0).(func(context.Context, relation.Relation) relation.Relation); ok { + r0 = rf(ctx, rel) + } else { + r0 = ret.Get(0).(relation.Relation) + } + + if rf, ok := ret.Get(1).(func(context.Context, relation.Relation) error); ok { + r1 = rf(ctx, rel) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RelationService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type RelationService_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - rel relation.Relation +func (_e *RelationService_Expecter) Create(ctx interface{}, rel interface{}) *RelationService_Create_Call { + return &RelationService_Create_Call{Call: _e.mock.On("Create", ctx, rel)} +} + +func (_c *RelationService_Create_Call) Run(run func(ctx context.Context, rel relation.Relation)) *RelationService_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(relation.Relation)) + }) + return _c +} + +func (_c *RelationService_Create_Call) Return(_a0 relation.Relation, _a1 error) *RelationService_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RelationService_Create_Call) RunAndReturn(run func(context.Context, relation.Relation) (relation.Relation, error)) *RelationService_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, rel +func (_m *RelationService) Delete(ctx context.Context, rel relation.Relation) error { + ret := _m.Called(ctx, rel) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, relation.Relation) error); ok { + r0 = rf(ctx, rel) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RelationService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type RelationService_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - rel relation.Relation +func (_e *RelationService_Expecter) Delete(ctx interface{}, rel interface{}) *RelationService_Delete_Call { + return &RelationService_Delete_Call{Call: _e.mock.On("Delete", ctx, rel)} +} + +func (_c *RelationService_Delete_Call) Run(run func(ctx context.Context, rel relation.Relation)) *RelationService_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(relation.Relation)) + }) + return _c +} + +func (_c *RelationService_Delete_Call) Return(_a0 error) *RelationService_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RelationService_Delete_Call) RunAndReturn(run func(context.Context, relation.Relation) error) *RelationService_Delete_Call { + _c.Call.Return(run) + return _c +} + +// NewRelationService creates a new instance of RelationService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRelationService(t interface { + mock.TestingT + Cleanup(func()) +}) *RelationService { + mock := &RelationService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/mocks/repository.go b/core/resource/mocks/repository.go new file mode 100644 index 000000000..32cd3b036 --- /dev/null +++ b/core/resource/mocks/repository.go @@ -0,0 +1,371 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + resource "github.com/raystack/frontier/core/resource" + mock "github.com/stretchr/testify/mock" +) + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +type Repository_Expecter struct { + mock *mock.Mock +} + +func (_m *Repository) EXPECT() *Repository_Expecter { + return &Repository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, _a1 +func (_m *Repository) Create(ctx context.Context, _a1 resource.Resource) (resource.Resource, error) { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 resource.Resource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, resource.Resource) (resource.Resource, error)); ok { + return rf(ctx, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, resource.Resource) resource.Resource); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Get(0).(resource.Resource) + } + + if rf, ok := ret.Get(1).(func(context.Context, resource.Resource) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type Repository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - _a1 resource.Resource +func (_e *Repository_Expecter) Create(ctx interface{}, _a1 interface{}) *Repository_Create_Call { + return &Repository_Create_Call{Call: _e.mock.On("Create", ctx, _a1)} +} + +func (_c *Repository_Create_Call) Run(run func(ctx context.Context, _a1 resource.Resource)) *Repository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(resource.Resource)) + }) + return _c +} + +func (_c *Repository_Create_Call) Return(_a0 resource.Resource, _a1 error) *Repository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_Create_Call) RunAndReturn(run func(context.Context, resource.Resource) (resource.Resource, error)) *Repository_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *Repository) Delete(ctx context.Context, id string) error { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Repository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type Repository_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Repository_Expecter) Delete(ctx interface{}, id interface{}) *Repository_Delete_Call { + return &Repository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} +} + +func (_c *Repository_Delete_Call) Run(run func(ctx context.Context, id string)) *Repository_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Repository_Delete_Call) Return(_a0 error) *Repository_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Repository_Delete_Call) RunAndReturn(run func(context.Context, string) error) *Repository_Delete_Call { + _c.Call.Return(run) + return _c +} + +// GetByID provides a mock function with given fields: ctx, id +func (_m *Repository) GetByID(ctx context.Context, id string) (resource.Resource, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetByID") + } + + var r0 resource.Resource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (resource.Resource, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) resource.Resource); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(resource.Resource) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_GetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByID' +type Repository_GetByID_Call struct { + *mock.Call +} + +// GetByID is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Repository_Expecter) GetByID(ctx interface{}, id interface{}) *Repository_GetByID_Call { + return &Repository_GetByID_Call{Call: _e.mock.On("GetByID", ctx, id)} +} + +func (_c *Repository_GetByID_Call) Run(run func(ctx context.Context, id string)) *Repository_GetByID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Repository_GetByID_Call) Return(_a0 resource.Resource, _a1 error) *Repository_GetByID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_GetByID_Call) RunAndReturn(run func(context.Context, string) (resource.Resource, error)) *Repository_GetByID_Call { + _c.Call.Return(run) + return _c +} + +// GetByURN provides a mock function with given fields: ctx, urn +func (_m *Repository) GetByURN(ctx context.Context, urn string) (resource.Resource, error) { + ret := _m.Called(ctx, urn) + + if len(ret) == 0 { + panic("no return value specified for GetByURN") + } + + var r0 resource.Resource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (resource.Resource, error)); ok { + return rf(ctx, urn) + } + if rf, ok := ret.Get(0).(func(context.Context, string) resource.Resource); ok { + r0 = rf(ctx, urn) + } else { + r0 = ret.Get(0).(resource.Resource) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, urn) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_GetByURN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByURN' +type Repository_GetByURN_Call struct { + *mock.Call +} + +// GetByURN is a helper method to define mock.On call +// - ctx context.Context +// - urn string +func (_e *Repository_Expecter) GetByURN(ctx interface{}, urn interface{}) *Repository_GetByURN_Call { + return &Repository_GetByURN_Call{Call: _e.mock.On("GetByURN", ctx, urn)} +} + +func (_c *Repository_GetByURN_Call) Run(run func(ctx context.Context, urn string)) *Repository_GetByURN_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Repository_GetByURN_Call) Return(_a0 resource.Resource, _a1 error) *Repository_GetByURN_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_GetByURN_Call) RunAndReturn(run func(context.Context, string) (resource.Resource, error)) *Repository_GetByURN_Call { + _c.Call.Return(run) + return _c +} + +// List provides a mock function with given fields: ctx, flt +func (_m *Repository) List(ctx context.Context, flt resource.Filter) ([]resource.Resource, error) { + ret := _m.Called(ctx, flt) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []resource.Resource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, resource.Filter) ([]resource.Resource, error)); ok { + return rf(ctx, flt) + } + if rf, ok := ret.Get(0).(func(context.Context, resource.Filter) []resource.Resource); ok { + r0 = rf(ctx, flt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]resource.Resource) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, resource.Filter) error); ok { + r1 = rf(ctx, flt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type Repository_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - flt resource.Filter +func (_e *Repository_Expecter) List(ctx interface{}, flt interface{}) *Repository_List_Call { + return &Repository_List_Call{Call: _e.mock.On("List", ctx, flt)} +} + +func (_c *Repository_List_Call) Run(run func(ctx context.Context, flt resource.Filter)) *Repository_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(resource.Filter)) + }) + return _c +} + +func (_c *Repository_List_Call) Return(_a0 []resource.Resource, _a1 error) *Repository_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_List_Call) RunAndReturn(run func(context.Context, resource.Filter) ([]resource.Resource, error)) *Repository_List_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, _a1 +func (_m *Repository) Update(ctx context.Context, _a1 resource.Resource) (resource.Resource, error) { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 resource.Resource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, resource.Resource) (resource.Resource, error)); ok { + return rf(ctx, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, resource.Resource) resource.Resource); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Get(0).(resource.Resource) + } + + if rf, ok := ret.Get(1).(func(context.Context, resource.Resource) error); ok { + r1 = rf(ctx, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type Repository_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - _a1 resource.Resource +func (_e *Repository_Expecter) Update(ctx interface{}, _a1 interface{}) *Repository_Update_Call { + return &Repository_Update_Call{Call: _e.mock.On("Update", ctx, _a1)} +} + +func (_c *Repository_Update_Call) Run(run func(ctx context.Context, _a1 resource.Resource)) *Repository_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(resource.Resource)) + }) + return _c +} + +func (_c *Repository_Update_Call) Return(_a0 resource.Resource, _a1 error) *Repository_Update_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_Update_Call) RunAndReturn(run func(context.Context, resource.Resource) (resource.Resource, error)) *Repository_Update_Call { + _c.Call.Return(run) + return _c +} + +// NewRepository creates a new instance of Repository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *Repository { + mock := &Repository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/resource/service.go b/core/resource/service.go index 7adf1e825..0f46cd33e 100644 --- a/core/resource/service.go +++ b/core/resource/service.go @@ -11,6 +11,7 @@ import ( "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/project" + patmodels "github.com/raystack/frontier/core/userpat/models" "github.com/raystack/frontier/pkg/utils" "github.com/raystack/frontier/core/relation" @@ -36,6 +37,10 @@ type OrgService interface { Get(ctx context.Context, idOrName string) (organization.Organization, error) } +type PATService interface { + GetByID(ctx context.Context, id string) (patmodels.PAT, error) +} + type Service struct { repository Repository configRepository ConfigRepository @@ -43,11 +48,13 @@ type Service struct { authnService AuthnService projectService ProjectService orgService OrgService + patService PATService } func NewService(repository Repository, configRepository ConfigRepository, relationService RelationService, authnService AuthnService, - projectService ProjectService, orgService OrgService) *Service { + projectService ProjectService, orgService OrgService, + patService PATService) *Service { return &Service{ repository: repository, configRepository: configRepository, @@ -55,6 +62,7 @@ func NewService(repository Repository, configRepository ConfigRepository, authnService: authnService, projectService: projectService, orgService: orgService, + patService: patService, } } @@ -163,12 +171,17 @@ func (s Service) AddResourceOwner(ctx context.Context, res Resource) error { } func (s Service) CheckAuthz(ctx context.Context, check Check) (bool, error) { - relSubject, err := s.buildRelationSubject(ctx, check.Subject) + relObject, err := s.buildRelationObject(ctx, check.Object) if err != nil { return false, err } - relObject, err := s.buildRelationObject(ctx, check.Object) + // PAT scope — early exit if denied + if allowed, err := s.checkPATScope(ctx, check.Subject, relObject, check.Permission); err != nil || !allowed { + return false, err + } + + relSubject, err := s.buildRelationSubject(ctx, check.Subject) if err != nil { return false, err } @@ -183,6 +196,10 @@ func (s Service) CheckAuthz(ctx context.Context, check Check) (bool, error) { func (s Service) buildRelationSubject(ctx context.Context, sub relation.Subject) (relation.Subject, error) { // use existing if passed in request if sub.ID != "" && sub.Namespace != "" { + // PAT subject → resolve to underlying user for authorization + if sub.Namespace == schema.PATPrincipal { + return s.resolvePATUser(ctx, sub.ID) + } return sub, nil } @@ -190,6 +207,10 @@ func (s Service) buildRelationSubject(ctx context.Context, sub relation.Subject) if err != nil { return relation.Subject{}, err } + // PAT principal → use underlying user for authorization + if principal.PAT != nil { + return relation.Subject{ID: principal.PAT.UserID, Namespace: schema.UserPrincipal}, nil + } return relation.Subject{ ID: principal.ID, Namespace: principal.Type, @@ -229,26 +250,147 @@ func (s Service) buildRelationObject(ctx context.Context, obj relation.Object) ( return obj, nil } +// resolvePATUser resolves a PAT ID to its owning user subject. +// Tries context first(cached), falls back to DB (for federated checks with explicit subject). +func (s Service) resolvePATUser(ctx context.Context, patID string) (relation.Subject, error) { + principal, err := s.authnService.GetPrincipal(ctx) + if err == nil && principal.PAT != nil && principal.PAT.ID == patID { + return relation.Subject{ID: principal.PAT.UserID, Namespace: schema.UserPrincipal}, nil + } + + pat, err := s.patService.GetByID(ctx, patID) + if err != nil { + return relation.Subject{}, err + } + return relation.Subject{ID: pat.UserID, Namespace: schema.UserPrincipal}, nil +} + +// resolvePATID returns the PAT ID to scope-check, if any. +// Explicit app/pat subject takes precedence (federated check by admin), +// otherwise falls back to the authenticated principal's PAT. +func (s Service) resolvePATID(ctx context.Context, subject relation.Subject) string { + if subject.Namespace == schema.PATPrincipal && subject.ID != "" { + return subject.ID + } + principal, _ := s.authnService.GetPrincipal(ctx) + if principal.PAT != nil { + return principal.PAT.ID + } + return "" +} + +// checkPATScope checks if the PAT has scope for the given permission on the object. +// Returns (true, nil) if no PAT is involved. +func (s Service) checkPATScope(ctx context.Context, subject relation.Subject, object relation.Object, permission string) (bool, error) { + patID := s.resolvePATID(ctx, subject) + if patID == "" { + return true, nil + } + return s.relationService.CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: object, + RelationName: permission, + }) +} + +// BatchCheck checks permissions for multiple resource checks. +// For PAT requests, it first batch-checks PAT scope, then only runs user permission +// checks for scope-allowed items. Scope-denied items return false directly. func (s Service) BatchCheck(ctx context.Context, checks []Check) ([]relation.CheckPair, error) { - relations := make([]relation.Relation, 0, len(checks)) - for _, check := range checks { - // we can parallelize this to speed up the process + relations, patScopeRelations, patScopeIdx, err := s.buildBatchRelations(ctx, checks) + if err != nil { + return nil, err + } + + // no PAT involved — straight to user permission check + if len(patScopeRelations) == 0 { + return s.relationService.BatchCheckPermission(ctx, relations) + } + + // PAT scope gate — check which items the PAT has scope for + scopeDenied, err := s.batchCheckPATScope(ctx, patScopeRelations, patScopeIdx) + if err != nil { + return nil, err + } + + // run user permission checks only for scope-allowed items, merge results + return s.batchCheckWithScopeFilter(ctx, relations, scopeDenied) +} + +// buildBatchRelations resolves objects/subjects and builds parallel PAT scope relations. +// Returns user relations, PAT scope relations, and index mapping from scope back to user relations. +func (s Service) buildBatchRelations(ctx context.Context, checks []Check) ( + relations, patScopeRelations []relation.Relation, patScopeIdx []int, err error, +) { + relations = make([]relation.Relation, 0, len(checks)) + for i, check := range checks { relObject, err := s.buildRelationObject(ctx, check.Object) if err != nil { - return nil, err + return nil, nil, nil, err } - relSubject, err := s.buildRelationSubject(ctx, check.Subject) if err != nil { - return nil, err + return nil, nil, nil, err } relations = append(relations, relation.Relation{ Subject: relSubject, Object: relObject, RelationName: check.Permission, }) + + if patID := s.resolvePATID(ctx, check.Subject); patID != "" { + patScopeRelations = append(patScopeRelations, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relObject, + RelationName: check.Permission, + }) + patScopeIdx = append(patScopeIdx, i) + } + } + return relations, patScopeRelations, patScopeIdx, nil +} + +// batchCheckPATScope runs a batch scope check and returns the set of denied relation indices. +func (s Service) batchCheckPATScope(ctx context.Context, patScopeRelations []relation.Relation, patScopeIdx []int) (map[int]bool, error) { + scopeResults, err := s.relationService.BatchCheckPermission(ctx, patScopeRelations) + if err != nil { + return nil, err + } + denied := make(map[int]bool, len(scopeResults)) + for j, sr := range scopeResults { + if !sr.Status { + denied[patScopeIdx[j]] = true + } + } + return denied, nil +} + +// batchCheckWithScopeFilter runs user permission checks for scope-allowed items +// and returns merged results where scope-denied items are false. +func (s Service) batchCheckWithScopeFilter(ctx context.Context, relations []relation.Relation, scopeDenied map[int]bool) ([]relation.CheckPair, error) { + var allowedRelations []relation.Relation + var allowedIdx []int + for i, rel := range relations { + if !scopeDenied[i] { + allowedRelations = append(allowedRelations, rel) + allowedIdx = append(allowedIdx, i) + } + } + + results := make([]relation.CheckPair, len(relations)) + for i := range results { + results[i] = relation.CheckPair{Relation: relations[i], Status: false} + } + if len(allowedRelations) > 0 { + userResults, err := s.relationService.BatchCheckPermission(ctx, allowedRelations) + if err != nil { + return nil, err + } + for j, idx := range allowedIdx { + results[idx] = userResults[j] + } } - return s.relationService.BatchCheckPermission(ctx, relations) + return results, nil } func (s Service) Delete(ctx context.Context, namespaceID, id string) error { diff --git a/core/resource/service_test.go b/core/resource/service_test.go new file mode 100644 index 000000000..109d15a29 --- /dev/null +++ b/core/resource/service_test.go @@ -0,0 +1,307 @@ +package resource_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/raystack/frontier/core/authenticate" + "github.com/raystack/frontier/core/relation" + "github.com/raystack/frontier/core/resource" + "github.com/raystack/frontier/core/resource/mocks" + patmodels "github.com/raystack/frontier/core/userpat/models" + "github.com/raystack/frontier/internal/bootstrap/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func newTestService(t *testing.T) (*mocks.Repository, *mocks.ConfigRepository, *mocks.RelationService, *mocks.AuthnService, *mocks.ProjectService, *mocks.OrgService, *mocks.PATService, *resource.Service) { + t.Helper() + repo := mocks.NewRepository(t) + configRepo := mocks.NewConfigRepository(t) + relationSvc := mocks.NewRelationService(t) + authnSvc := mocks.NewAuthnService(t) + projectSvc := mocks.NewProjectService(t) + orgSvc := mocks.NewOrgService(t) + patSvc := mocks.NewPATService(t) + svc := resource.NewService(repo, configRepo, relationSvc, authnSvc, projectSvc, orgSvc, patSvc) + return repo, configRepo, relationSvc, authnSvc, projectSvc, orgSvc, patSvc, svc +} + +func TestCheckAuthz_NonPAT(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + userID := uuid.New().String() + orgID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: userID, + Type: schema.UserPrincipal, + }, nil).Maybe() + + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.GetPermission, + }).Return(true, nil) + + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Permission: schema.GetPermission, + }) + assert.NoError(t, err) + assert.True(t, result) +} + +func TestCheckAuthz_PATScopeAllowed(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: patID, + Type: schema.PATPrincipal, + PAT: &patmodels.PAT{ID: patID, UserID: userID}, + }, nil).Maybe() + + // PAT scope check — allowed + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.GetPermission, + }).Return(true, nil) + + // User permission check — allowed + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.GetPermission, + }).Return(true, nil) + + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Permission: schema.GetPermission, + }) + assert.NoError(t, err) + assert.True(t, result) +} + +func TestCheckAuthz_PATScopeDenied(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: patID, + Type: schema.PATPrincipal, + PAT: &patmodels.PAT{ID: patID, UserID: userID}, + }, nil).Maybe() + + // PAT scope check — denied + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.UpdatePermission, + }).Return(false, nil) + + // User check should NOT be called (early exit) + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Permission: schema.UpdatePermission, + }) + assert.NoError(t, err) + assert.False(t, result) +} + +func TestCheckAuthz_PATScopeAllowed_UserDenied(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: patID, + Type: schema.PATPrincipal, + PAT: &patmodels.PAT{ID: patID, UserID: userID}, + }, nil).Maybe() + + // PAT scope check — allowed + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.DeletePermission, + }).Return(true, nil) + + // User permission check — denied + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.DeletePermission, + }).Return(false, nil) + + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Permission: schema.DeletePermission, + }) + assert.NoError(t, err) + assert.False(t, result) +} + +func TestCheckAuthz_ExplicitPATSubject_ScopeAllowed(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, patSvc, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + + // Principal is NOT a PAT (e.g., superuser making federated check with PAT subject) + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: uuid.New().String(), + Type: schema.UserPrincipal, + }, nil).Maybe() + + // PAT scope check for explicit subject — allowed + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.GetPermission, + }).Return(true, nil) + + // Federated check passes explicit app/pat subject — needs DB lookup + patSvc.EXPECT().GetByID(ctx, patID).Return(patmodels.PAT{ + ID: patID, + UserID: userID, + }, nil) + + // User permission check (resolved from PAT) + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.GetPermission, + }).Return(true, nil) + + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Permission: schema.GetPermission, + }) + assert.NoError(t, err) + assert.True(t, result) +} + +func TestCheckAuthz_ExplicitPATSubject_ScopeDenied(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + orgID := uuid.New().String() + + // Principal is NOT a PAT (e.g., superuser making federated check with PAT subject) + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: uuid.New().String(), + Type: schema.UserPrincipal, + }, nil).Maybe() + + // PAT scope check for explicit subject — denied + relationSvc.EXPECT().CheckPermission(ctx, relation.Relation{ + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + RelationName: schema.UpdatePermission, + }).Return(false, nil) + + // User check should NOT be called — PAT scope denied + result, err := svc.CheckAuthz(ctx, resource.Check{ + Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, + Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, + Permission: schema.UpdatePermission, + }) + assert.NoError(t, err) + assert.False(t, result) +} + +func TestBatchCheck_PATScopeAllowed(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + projID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: patID, + Type: schema.PATPrincipal, + PAT: &patmodels.PAT{ID: patID, UserID: userID}, + }, nil).Maybe() + + checks := []resource.Check{ + {Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, Permission: schema.GetPermission}, + {Object: relation.Object{ID: projID, Namespace: schema.ProjectNamespace}, Permission: schema.GetPermission}, + } + + // PAT scope batch — all allowed + relationSvc.EXPECT().BatchCheckPermission(ctx, []relation.Relation{ + {Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, RelationName: schema.GetPermission}, + {Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, Object: relation.Object{ID: projID, Namespace: schema.ProjectNamespace}, RelationName: schema.GetPermission}, + }).Return([]relation.CheckPair{ + {Status: true}, + {Status: true}, + }, nil) + + // User check batch + relationSvc.EXPECT().BatchCheckPermission(ctx, []relation.Relation{ + {Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, RelationName: schema.GetPermission}, + {Subject: relation.Subject{ID: userID, Namespace: schema.UserPrincipal}, Object: relation.Object{ID: projID, Namespace: schema.ProjectNamespace}, RelationName: schema.GetPermission}, + }).Return([]relation.CheckPair{ + {Status: true}, + {Status: true}, + }, nil) + + results, err := svc.BatchCheck(ctx, checks) + assert.NoError(t, err) + assert.Len(t, results, 2) + assert.True(t, results[0].Status) + assert.True(t, results[1].Status) +} + +func TestBatchCheck_PATScopeDenied(t *testing.T) { + ctx := context.Background() + _, _, relationSvc, authnSvc, _, _, _, svc := newTestService(t) + + patID := uuid.New().String() + userID := uuid.New().String() + orgID := uuid.New().String() + + authnSvc.EXPECT().GetPrincipal(ctx, mock.Anything).Return(authenticate.Principal{ + ID: patID, + Type: schema.PATPrincipal, + PAT: &patmodels.PAT{ID: patID, UserID: userID}, + }, nil).Maybe() + + checks := []resource.Check{ + {Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, Permission: schema.UpdatePermission}, + } + + // PAT scope batch — denied + relationSvc.EXPECT().BatchCheckPermission(ctx, []relation.Relation{ + {Subject: relation.Subject{ID: patID, Namespace: schema.PATPrincipal}, Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace}, RelationName: schema.UpdatePermission}, + }).Return([]relation.CheckPair{ + {Status: false}, + }, nil) + + // User check should NOT be called — scope-denied items return false directly + results, err := svc.BatchCheck(ctx, checks) + assert.NoError(t, err) + assert.Len(t, results, 1) + assert.False(t, results[0].Status) +} diff --git a/core/userpat/mocks/repository.go b/core/userpat/mocks/repository.go index b4a022075..0e0bfb811 100644 --- a/core/userpat/mocks/repository.go +++ b/core/userpat/mocks/repository.go @@ -139,6 +139,63 @@ func (_c *Repository_Create_Call) RunAndReturn(run func(context.Context, models. return _c } +// GetByID provides a mock function with given fields: ctx, id +func (_m *Repository) GetByID(ctx context.Context, id string) (models.PAT, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetByID") + } + + var r0 models.PAT + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (models.PAT, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) models.PAT); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(models.PAT) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_GetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByID' +type Repository_GetByID_Call struct { + *mock.Call +} + +// GetByID is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *Repository_Expecter) GetByID(ctx interface{}, id interface{}) *Repository_GetByID_Call { + return &Repository_GetByID_Call{Call: _e.mock.On("GetByID", ctx, id)} +} + +func (_c *Repository_GetByID_Call) Run(run func(ctx context.Context, id string)) *Repository_GetByID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Repository_GetByID_Call) Return(_a0 models.PAT, _a1 error) *Repository_GetByID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_GetByID_Call) RunAndReturn(run func(context.Context, string) (models.PAT, error)) *Repository_GetByID_Call { + _c.Call.Return(run) + return _c +} + // GetBySecretHash provides a mock function with given fields: ctx, secretHash func (_m *Repository) GetBySecretHash(ctx context.Context, secretHash string) (models.PAT, error) { ret := _m.Called(ctx, secretHash) diff --git a/core/userpat/service.go b/core/userpat/service.go index da9ca16e4..4d082e63e 100644 --- a/core/userpat/service.go +++ b/core/userpat/service.go @@ -87,6 +87,10 @@ func (s *Service) ValidateExpiry(expiresAt time.Time) error { return nil } +func (s *Service) GetByID(ctx context.Context, id string) (patmodels.PAT, error) { + return s.repo.GetByID(ctx, id) +} + // Create generates a new PAT and returns it with the plaintext value. // The plaintext value is only available at creation time. func (s *Service) Create(ctx context.Context, req CreateRequest) (patmodels.PAT, string, error) { diff --git a/core/userpat/userpat.go b/core/userpat/userpat.go index 0a3dcc3f7..65befac1b 100644 --- a/core/userpat/userpat.go +++ b/core/userpat/userpat.go @@ -10,6 +10,7 @@ import ( type Repository interface { Create(ctx context.Context, pat models.PAT) (models.PAT, error) CountActive(ctx context.Context, userID, orgID string) (int64, error) + GetByID(ctx context.Context, id string) (models.PAT, error) GetBySecretHash(ctx context.Context, secretHash string) (models.PAT, error) UpdateLastUsedAt(ctx context.Context, id string, at time.Time) error } diff --git a/internal/store/postgres/userpat_repository.go b/internal/store/postgres/userpat_repository.go index d53f9bf6c..e4efdb222 100644 --- a/internal/store/postgres/userpat_repository.go +++ b/internal/store/postgres/userpat_repository.go @@ -87,6 +87,31 @@ func (r UserPATRepository) CountActive(ctx context.Context, userID, orgID string return count, nil } +func (r UserPATRepository) GetByID(ctx context.Context, id string) (models.PAT, error) { + query, params, err := dialect.From(TABLE_USER_PATS). + Select(&UserPAT{}). + Where( + goqu.Ex{"id": id}, + goqu.Ex{"deleted_at": nil}, + ).Limit(1).ToSQL() + if err != nil { + return models.PAT{}, fmt.Errorf("%w: %w", queryErr, err) + } + + var model UserPAT + if err = r.dbc.WithTimeout(ctx, TABLE_USER_PATS, "GetByID", func(ctx context.Context) error { + return r.dbc.GetContext(ctx, &model, query, params...) + }); err != nil { + err = checkPostgresError(err) + if errors.Is(err, sql.ErrNoRows) { + return models.PAT{}, paterrors.ErrNotFound + } + return models.PAT{}, fmt.Errorf("%w: %w", dbErr, err) + } + + return model.transform() +} + func (r UserPATRepository) GetBySecretHash(ctx context.Context, secretHash string) (models.PAT, error) { query, params, err := dialect.From(TABLE_USER_PATS). Select(&UserPAT{}). diff --git a/test/e2e/regression/pat_test.go b/test/e2e/regression/pat_test.go new file mode 100644 index 000000000..a4fd698ae --- /dev/null +++ b/test/e2e/regression/pat_test.go @@ -0,0 +1,365 @@ +//go:build !race + +package e2e_test + +import ( + "context" + "os" + "path" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/authenticate" + testusers "github.com/raystack/frontier/core/authenticate/test_users" + "github.com/raystack/frontier/core/userpat" + "github.com/raystack/frontier/internal/bootstrap/schema" + "github.com/raystack/frontier/pkg/server" + + "github.com/raystack/frontier/config" + "github.com/raystack/frontier/pkg/logger" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/raystack/frontier/test/e2e/testbench" + "github.com/stretchr/testify/suite" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type PATRegressionTestSuite struct { + suite.Suite + testBench *testbench.TestBench + adminCookie string + roleIDs map[string]string // role name -> UUID +} + +func (s *PATRegressionTestSuite) SetupSuite() { + wd, err := os.Getwd() + s.Require().Nil(err) + testDataPath := path.Join("file://", wd, fixturesDir) + + connectPort, err := testbench.GetFreePort() + s.Require().NoError(err) + + appConfig := &config.Frontier{ + Log: logger.Config{ + Level: "error", + }, + App: server.Config{ + Host: "localhost", + Connect: server.ConnectConfig{Port: connectPort}, + ResourcesConfigPath: path.Join(testDataPath, "resource"), + Authentication: authenticate.Config{ + Session: authenticate.SessionConfig{ + HashSecretKey: "hash-secret-should-be-32-chars--", + BlockSecretKey: "hash-secret-should-be-32-chars--", + Validity: time.Hour, + }, + Token: authenticate.TokenConfig{ + RSAPath: "testdata/jwks.json", + Issuer: "frontier", + }, + MailOTP: authenticate.MailOTPConfig{ + Subject: "{{.Otp}}", + Body: "{{.Otp}}", + Validity: 10 * time.Minute, + }, + TestUsers: testusers.Config{Enabled: true, Domain: "raystack.org", OTP: testbench.TestOTP}, + }, + PAT: userpat.Config{Enabled: true, Prefix: "fpt", MaxPerUserPerOrg: 50, MaxLifetime: "8760h"}, + }, + } + + s.testBench, err = testbench.Init(appConfig) + s.Require().NoError(err) + + ctx := context.Background() + + adminCookie, err := testbench.AuthenticateUser(ctx, s.testBench.Client, testbench.OrgAdminEmail) + s.Require().NoError(err) + s.adminCookie = adminCookie + + s.Require().NoError(testbench.BootstrapUsers(ctx, s.testBench.Client, adminCookie)) + s.Require().NoError(testbench.BootstrapOrganizations(ctx, s.testBench.Client, adminCookie)) + s.Require().NoError(testbench.BootstrapProject(ctx, s.testBench.Client, adminCookie)) + s.Require().NoError(testbench.BootstrapGroup(ctx, s.testBench.Client, adminCookie)) + + // build role name → UUID map for PAT creation (requires UUIDs) + ctxAdmin := testbench.ContextWithAuth(ctx, adminCookie) + rolesResp, err := s.testBench.Client.ListRoles(ctxAdmin, connect.NewRequest(&frontierv1beta1.ListRolesRequest{})) + s.Require().NoError(err) + s.roleIDs = make(map[string]string, len(rolesResp.Msg.GetRoles())) + for _, r := range rolesResp.Msg.GetRoles() { + s.roleIDs[r.GetName()] = r.GetId() + } +} + +func (s *PATRegressionTestSuite) TearDownSuite() { + err := s.testBench.Close() + s.Require().NoError(err) +} + +func (s *PATRegressionTestSuite) roleID(name string) string { + id, ok := s.roleIDs[name] + s.Require().True(ok, "role %q not found in platform roles", name) + return id +} + +func getPATCtx(token string) context.Context { + return testbench.ContextWithHeaders(context.Background(), map[string]string{ + "Authorization": "Bearer " + token, + }) +} + +func (s *PATRegressionTestSuite) createOrgAndProjects(ctxAdmin context.Context, orgName, proj1Name, proj2Name string) (string, string, string) { + createOrgResp, err := s.testBench.Client.CreateOrganization(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateOrganizationRequest{ + Body: &frontierv1beta1.OrganizationRequestBody{ + Name: orgName, + }, + })) + s.Require().NoError(err) + orgID := createOrgResp.Msg.GetOrganization().GetId() + + proj1Resp, err := s.testBench.Client.CreateProject(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateProjectRequest{ + Body: &frontierv1beta1.ProjectRequestBody{ + Name: proj1Name, + OrgId: orgID, + }, + })) + s.Require().NoError(err) + proj1ID := proj1Resp.Msg.GetProject().GetId() + + var proj2ID string + if proj2Name != "" { + proj2Resp, err := s.testBench.Client.CreateProject(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateProjectRequest{ + Body: &frontierv1beta1.ProjectRequestBody{ + Name: proj2Name, + OrgId: orgID, + }, + })) + s.Require().NoError(err) + proj2ID = proj2Resp.Msg.GetProject().GetId() + } + + return orgID, proj1ID, proj2ID +} + +func (s *PATRegressionTestSuite) createPAT(ctxAdmin context.Context, orgID, title string, roleIDs, projectIDs []string) (string, string) { + patResp, err := s.testBench.Client.CreateCurrentUserPAT(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ + Title: title, + OrgId: orgID, + RoleIds: roleIDs, + ProjectIds: projectIDs, + ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Hour)), + })) + s.Require().NoError(err) + s.Require().NotEmpty(patResp.Msg.GetPat().GetToken()) + return patResp.Msg.GetPat().GetId(), patResp.Msg.GetPat().GetToken() +} + +func (s *PATRegressionTestSuite) checkPermission(ctx context.Context, namespace, id, permission string) bool { + resp, err := s.testBench.Client.CheckResourcePermission(ctx, connect.NewRequest(&frontierv1beta1.CheckResourcePermissionRequest{ + Resource: schema.JoinNamespaceAndResourceID(namespace, id), + Permission: permission, + })) + s.Require().NoError(err) + return resp.Msg.GetStatus() +} + +func (s *PATRegressionTestSuite) TestPATScope_OrgViewer_ProjectViewer() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + orgID, proj1ID, proj2ID := s.createOrgAndProjects(ctxAdmin, "org-pat-ov-pv", "pat-ov-pv-p1", "pat-ov-pv-p2") + + _, patToken := s.createPAT(ctxAdmin, orgID, "pat-ov-pv", + []string{s.roleID(schema.RoleOrganizationViewer), s.roleID(schema.RoleProjectViewer)}, + []string{proj1ID}, + ) + patCtx := getPATCtx(patToken) + + s.Run("org get allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.GetPermission)) + }) + s.Run("org update denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.UpdatePermission)) + }) + s.Run("scoped project get allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.GetPermission)) + }) + s.Run("scoped project update denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.UpdatePermission)) + }) + s.Run("unscoped project get denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.ProjectNamespace, proj2ID, schema.GetPermission)) + }) + s.Run("batch check mixed results", func() { + batchResp, err := s.testBench.Client.BatchCheckPermission(patCtx, connect.NewRequest(&frontierv1beta1.BatchCheckPermissionRequest{ + Bodies: []*frontierv1beta1.BatchCheckPermissionBody{ + { + Resource: schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, orgID), + Permission: schema.GetPermission, + }, + { + Resource: schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, orgID), + Permission: schema.UpdatePermission, + }, + { + Resource: schema.JoinNamespaceAndResourceID(schema.ProjectNamespace, proj1ID), + Permission: schema.GetPermission, + }, + }, + })) + s.Require().NoError(err) + pairs := batchResp.Msg.GetPairs() + s.Require().Len(pairs, 3) + s.Assert().True(pairs[0].GetStatus(), "org:get should be true") + s.Assert().False(pairs[1].GetStatus(), "org:update should be false") + s.Assert().True(pairs[2].GetStatus(), "proj1:get should be true") + }) +} + +func (s *PATRegressionTestSuite) TestPATScope_OrgOwner() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + orgID, proj1ID, _ := s.createOrgAndProjects(ctxAdmin, "org-pat-oo", "pat-oo-p1", "") + + _, patToken := s.createPAT(ctxAdmin, orgID, "pat-oo", + []string{s.roleID(schema.RoleOrganizationOwner)}, + nil, + ) + patCtx := getPATCtx(patToken) + + s.Run("org get allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.GetPermission)) + }) + s.Run("org update allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.UpdatePermission)) + }) + s.Run("project get inherited from org owner", func() { + s.Assert().True(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.GetPermission)) + }) + s.Run("project update inherited from org owner", func() { + s.Assert().True(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.UpdatePermission)) + }) +} + +func (s *PATRegressionTestSuite) TestPATScope_OrgViewer_AllProjects() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + orgID, proj1ID, proj2ID := s.createOrgAndProjects(ctxAdmin, "org-pat-ov-ap", "pat-ov-ap-p1", "pat-ov-ap-p2") + + _, patToken := s.createPAT(ctxAdmin, orgID, "pat-ov-ap", + []string{s.roleID(schema.RoleOrganizationViewer), s.roleID(schema.RoleProjectOwner)}, + nil, // empty = all projects + ) + patCtx := getPATCtx(patToken) + + s.Run("org get allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.GetPermission)) + }) + s.Run("org update denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.UpdatePermission)) + }) + s.Run("proj1 update allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.UpdatePermission)) + }) + s.Run("proj2 update allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.ProjectNamespace, proj2ID, schema.UpdatePermission)) + }) +} + +func (s *PATRegressionTestSuite) TestPATScope_BillingManager() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + orgID, proj1ID, _ := s.createOrgAndProjects(ctxAdmin, "org-pat-bm", "pat-bm-p1", "") + + _, patToken := s.createPAT(ctxAdmin, orgID, "pat-bm", + []string{s.roleID("app_billing_manager")}, + nil, + ) + patCtx := getPATCtx(patToken) + + s.Run("org billingview allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.BillingViewPermission)) + }) + s.Run("org billingmanage allowed", func() { + s.Assert().True(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.BillingManagePermission)) + }) + s.Run("org get denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.GetPermission)) + }) + s.Run("org update denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.OrganizationNamespace, orgID, schema.UpdatePermission)) + }) + s.Run("project get denied", func() { + s.Assert().False(s.checkPermission(patCtx, schema.ProjectNamespace, proj1ID, schema.GetPermission)) + }) +} + +func (s *PATRegressionTestSuite) TestPATScope_Interceptor() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + + createOrgResp, err := s.testBench.Client.CreateOrganization(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateOrganizationRequest{ + Body: &frontierv1beta1.OrganizationRequestBody{ + Name: "org-pat-interceptor", + }, + })) + s.Require().NoError(err) + orgID := createOrgResp.Msg.GetOrganization().GetId() + + _, patToken := s.createPAT(ctxAdmin, orgID, "pat-interceptor", + []string{s.roleID(schema.RoleOrganizationViewer)}, + nil, + ) + patCtx := getPATCtx(patToken) + + // UpdateOrganization requires update permission — PAT only has viewer scope + _, err = s.testBench.Client.UpdateOrganization(patCtx, connect.NewRequest(&frontierv1beta1.UpdateOrganizationRequest{ + Id: orgID, + Body: &frontierv1beta1.OrganizationRequestBody{ + Name: "org-pat-interceptor", + Title: "updated title", + }, + })) + s.Assert().Error(err) + s.Assert().Equal(connect.CodePermissionDenied, connect.CodeOf(err)) +} + +func (s *PATRegressionTestSuite) TestPATScope_FederatedCheck() { + ctxAdmin := testbench.ContextWithAuth(context.Background(), s.adminCookie) + + createOrgResp, err := s.testBench.Client.CreateOrganization(ctxAdmin, connect.NewRequest(&frontierv1beta1.CreateOrganizationRequest{ + Body: &frontierv1beta1.OrganizationRequestBody{ + Name: "org-pat-federated", + }, + })) + s.Require().NoError(err) + orgID := createOrgResp.Msg.GetOrganization().GetId() + + patID, _ := s.createPAT(ctxAdmin, orgID, "pat-federated", + []string{s.roleID(schema.RoleOrganizationViewer)}, + nil, + ) + + patSubject := schema.JoinNamespaceAndResourceID(schema.PATPrincipal, patID) + orgResource := schema.JoinNamespaceAndResourceID(schema.OrganizationNamespace, orgID) + + s.Run("federated check get allowed", func() { + resp, err := s.testBench.AdminClient.CheckFederatedResourcePermission(ctxAdmin, + connect.NewRequest(&frontierv1beta1.CheckFederatedResourcePermissionRequest{ + Subject: patSubject, + Resource: orgResource, + Permission: schema.GetPermission, + })) + s.Require().NoError(err) + s.Assert().True(resp.Msg.GetStatus()) + }) + s.Run("federated check update denied", func() { + resp, err := s.testBench.AdminClient.CheckFederatedResourcePermission(ctxAdmin, + connect.NewRequest(&frontierv1beta1.CheckFederatedResourcePermissionRequest{ + Subject: patSubject, + Resource: orgResource, + Permission: schema.UpdatePermission, + })) + s.Require().NoError(err) + s.Assert().False(resp.Msg.GetStatus()) + }) +} + +func TestEndToEndPATRegressionTestSuite(t *testing.T) { + suite.Run(t, new(PATRegressionTestSuite)) +}