diff --git a/core/audit/audit.go b/core/audit/audit.go index 2af240bf8..c0ac6b898 100644 --- a/core/audit/audit.go +++ b/core/audit/audit.go @@ -74,19 +74,20 @@ const ( PolicyCreatedEvent EventName = "app.policy.created" PolicyDeletedEvent EventName = "app.policy.deleted" - OrgCreatedEvent EventName = "app.organization.created" - OrgUpdatedEvent EventName = "app.organization.updated" - OrgDeletedEvent EventName = "app.organization.deleted" - OrgDisabledEvent EventName = "app.organization.disabled" - OrgMemberCreatedEvent EventName = "app.organization.member.created" - OrgMemberDeletedEvent EventName = "app.organization.member.deleted" - OrgKycUpdatedEvent EventName = "app.organization.kyc.updated" - - ProjectCreatedEvent EventName = "app.project.created" - ProjectUpdatedEvent EventName = "app.project.updated" - ProjectDeletedEvent EventName = "app.project.deleted" - ProjectMemberRoleSetEvent EventName = "app.project.member.role.set" - ProjectMemberRemovedEvent EventName = "app.project.member.removed" + OrgCreatedEvent EventName = "app.organization.created" + OrgUpdatedEvent EventName = "app.organization.updated" + OrgDeletedEvent EventName = "app.organization.deleted" + OrgDisabledEvent EventName = "app.organization.disabled" + OrgMemberCreatedEvent EventName = "app.organization.member.created" + OrgMemberDeletedEvent EventName = "app.organization.member.deleted" + OrgMemberRoleChangedEvent EventName = "app.organization.member.role_changed" + OrgKycUpdatedEvent EventName = "app.organization.kyc.updated" + + ProjectCreatedEvent EventName = "app.project.created" + ProjectUpdatedEvent EventName = "app.project.updated" + ProjectDeletedEvent EventName = "app.project.deleted" + ProjectMemberRoleChangedEvent EventName = "app.project.member.role_changed" + ProjectMemberRemovedEvent EventName = "app.project.member.removed" ResourceCreatedEvent EventName = "app.resource.created" ResourceUpdatedEvent EventName = "app.resource.updated" diff --git a/core/organization/service.go b/core/organization/service.go index efaf8b66f..d5c54f61d 100644 --- a/core/organization/service.go +++ b/core/organization/service.go @@ -393,6 +393,47 @@ func (s Service) SetMemberRole(ctx context.Context, orgID, userID, newRoleID str return err } + // audit logging + org, err := s.repository.GetByID(ctx, orgID) + if err != nil { + return err + } + + usr, err := s.userService.GetByID(ctx, userID) + if err != nil { + return err + } + + _, auditErr := s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{ + Event: pkgAuditRecord.OrganizationMemberRoleChangedEvent, + Resource: auditrecord.Resource{ + ID: orgID, + Type: pkgAuditRecord.OrganizationType, + Name: org.Title, + }, + Target: &auditrecord.Target{ + ID: userID, + Type: pkgAuditRecord.UserType, + Name: usr.Title, + Metadata: map[string]any{ + "email": usr.Email, + "role_id": newRoleID, + }, + }, + OrgID: orgID, + OccurredAt: time.Now(), + }) + if auditErr != nil { + return auditErr + } + + audit.GetAuditor(ctx, orgID).LogWithAttrs(audit.OrgMemberRoleChangedEvent, audit.Target{ + ID: userID, + Type: schema.UserPrincipal, + }, map[string]string{ + "role_id": newRoleID, + }) + return nil } diff --git a/core/organization/service_test.go b/core/organization/service_test.go index dbcd47665..0c564e0eb 100644 --- a/core/organization/service_test.go +++ b/core/organization/service_test.go @@ -17,6 +17,7 @@ import ( "github.com/raystack/frontier/core/user" pat "github.com/raystack/frontier/core/userpat/models" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -379,7 +380,7 @@ func TestService_SetMemberRole(t *testing.T) { tests := []struct { name string - setup func(*mocks.Repository, *mocks.UserService, *mocks.RoleService, *mocks.PolicyService) + setup func(*mocks.Repository, *mocks.UserService, *mocks.RoleService, *mocks.PolicyService, *mocks.AuditRecordRepository) orgID string userID string newRoleID string @@ -387,7 +388,7 @@ func TestService_SetMemberRole(t *testing.T) { }{ { name: "should return error if org does not exist", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{}, organization.ErrNotExist) }, orgID: orgID, @@ -397,7 +398,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if org is disabled", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{State: organization.Disabled}, nil) }, orgID: orgID, @@ -407,7 +408,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if user does not exist", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{}, user.ErrNotExist) }, @@ -418,7 +419,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if role does not exist", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) roleSvc.EXPECT().Get(ctx, memberRoleID).Return(role.Role{}, role.ErrNotExist) @@ -430,7 +431,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if role is not valid for org scope", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) // role exists but has project scope, not org scope @@ -443,7 +444,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if user is not a member of the org", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) roleSvc.EXPECT().Get(ctx, memberRoleID).Return(role.Role{ID: memberRoleID, Name: "member", Scopes: []string{schema.OrganizationNamespace}}, nil) @@ -461,7 +462,7 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should return error if demoting last owner", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, _ *mocks.AuditRecordRepository) { repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) roleSvc.EXPECT().Get(ctx, memberRoleID).Return(role.Role{ID: memberRoleID, Name: "member", Scopes: []string{schema.OrganizationNamespace}}, nil) @@ -486,9 +487,18 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should succeed when changing role with multiple owners", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { - repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) - userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, auditRepo *mocks.AuditRecordRepository) { + repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ + ID: orgID, + Name: "test-org", + Title: "Test Organization", + State: organization.Enabled, + }, nil).Times(2) + userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ + ID: userID, + Title: "test-user", + Email: "test-user@acme.dev", + }, nil).Times(2) roleSvc.EXPECT().Get(ctx, memberRoleID).Return(role.Role{ID: memberRoleID, Name: "member", Scopes: []string{schema.OrganizationNamespace}}, nil) // get user's existing policies - user is owner policySvc.EXPECT().List(ctx, policy.Filter{ @@ -516,6 +526,19 @@ func TestService_SetMemberRole(t *testing.T) { PrincipalID: userID, PrincipalType: schema.UserPrincipal, }).Return(policy.Policy{}, nil) + // audit logging + auditRepo.EXPECT().Create(ctx, mock.MatchedBy(func(ar auditrecord.AuditRecord) bool { + if ar.Target == nil { + return false + } + return ar.Event == pkgAuditRecord.OrganizationMemberRoleChangedEvent && + ar.Resource.ID == orgID && + ar.Resource.Name == "Test Organization" && + ar.Target.ID == userID && + ar.Target.Metadata["email"] == "test-user@acme.dev" && + ar.Target.Metadata["role_id"] == memberRoleID && + ar.OrgID == orgID + })).Return(auditrecord.AuditRecord{}, nil).Once() }, orgID: orgID, userID: userID, @@ -524,9 +547,18 @@ func TestService_SetMemberRole(t *testing.T) { }, { name: "should succeed when promoting to owner", - setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService) { - repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ID: orgID, State: organization.Enabled}, nil) - userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil) + setup: func(repo *mocks.Repository, userSvc *mocks.UserService, roleSvc *mocks.RoleService, policySvc *mocks.PolicyService, auditRepo *mocks.AuditRecordRepository) { + repo.EXPECT().GetByID(ctx, orgID).Return(organization.Organization{ + ID: orgID, + Name: "test-org", + Title: "Test Organization", + State: organization.Enabled, + }, nil).Times(2) + userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ + ID: userID, + Title: "test-user", + Email: "test-user@acme.dev", + }, nil).Times(2) roleSvc.EXPECT().Get(ctx, ownerRoleID).Return(role.Role{ID: ownerRoleID, Name: schema.RoleOrganizationOwner, Scopes: []string{schema.OrganizationNamespace}}, nil) // get user's existing policies - user is member policySvc.EXPECT().List(ctx, policy.Filter{ @@ -547,6 +579,19 @@ func TestService_SetMemberRole(t *testing.T) { PrincipalID: userID, PrincipalType: schema.UserPrincipal, }).Return(policy.Policy{}, nil) + // audit logging + auditRepo.EXPECT().Create(ctx, mock.MatchedBy(func(ar auditrecord.AuditRecord) bool { + if ar.Target == nil { + return false + } + return ar.Event == pkgAuditRecord.OrganizationMemberRoleChangedEvent && + ar.Resource.ID == orgID && + ar.Resource.Name == "Test Organization" && + ar.Target.ID == userID && + ar.Target.Metadata["email"] == "test-user@acme.dev" && + ar.Target.Metadata["role_id"] == ownerRoleID && + ar.OrgID == orgID + })).Return(auditrecord.AuditRecord{}, nil).Once() }, orgID: orgID, userID: userID, @@ -567,7 +612,7 @@ func TestService_SetMemberRole(t *testing.T) { mockRoleSvc := mocks.NewRoleService(t) if tt.setup != nil { - tt.setup(mockRepo, mockUserSvc, mockRoleSvc, mockPolicySvc) + tt.setup(mockRepo, mockUserSvc, mockRoleSvc, mockPolicySvc, mockAuditRecordRepo) } svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockAuditRecordRepo, mockRoleSvc) diff --git a/internal/api/v1beta1connect/project.go b/internal/api/v1beta1connect/project.go index 8ccd0dc89..93c45a895 100644 --- a/internal/api/v1beta1connect/project.go +++ b/internal/api/v1beta1connect/project.go @@ -401,7 +401,7 @@ func (h *ConnectHandler) SetProjectMemberRole(ctx context.Context, request *conn } } - audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRoleSetEvent, audit.ProjectTarget(projectID), map[string]string{ + audit.GetAuditor(ctx, "").LogWithAttrs(audit.ProjectMemberRoleChangedEvent, audit.ProjectTarget(projectID), map[string]string{ "principal_id": principalID, "principal_type": principalType, "role_id": roleID, diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index e7185c689..247129ff8 100644 --- a/pkg/auditrecord/consts.go +++ b/pkg/auditrecord/consts.go @@ -36,6 +36,7 @@ const ( OrganizationInvitedEvent Event = "organization.invited" OrganizationMemberAddedEvent Event = "organization.added" OrganizationMemberRemovedEvent Event = "organization.removed" + OrganizationMemberRoleChangedEvent Event = "organization.role_changed" OrganizationInvitationAcceptedEvent Event = "organization.accepted" // KYC Events