diff --git a/cmd/serve.go b/cmd/serve.go index 3985ec54e..b5c4767f7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -16,6 +16,7 @@ import ( "github.com/goto/shield/config" "github.com/goto/shield/core/action" + "github.com/goto/shield/core/activity" "github.com/goto/shield/core/group" "github.com/goto/shield/core/namespace" "github.com/goto/shield/core/organization" @@ -90,18 +91,24 @@ func StartServer(logger *log.Zap, cfg *config.Shield) error { } // + activityRepository := postgres.NewActivityRepository(dbClient) + activityService := activity.NewService(activityRepository) + + userRepository := postgres.NewUserRepository(dbClient) + userService := user.NewService(userRepository, activityService) + actionRepository := postgres.NewActionRepository(dbClient) - actionService := action.NewService(actionRepository) + actionService := action.NewService(actionRepository, userService, activityService) roleRepository := postgres.NewRoleRepository(dbClient) - roleService := role.NewService(roleRepository) + roleService := role.NewService(roleRepository, userService, activityService) policyPGRepository := postgres.NewPolicyRepository(dbClient) policySpiceRepository := spicedb.NewPolicyRepository(spiceDBClient) - policyService := policy.NewService(policyPGRepository) + policyService := policy.NewService(policyPGRepository, userService, activityService) namespaceRepository := postgres.NewNamespaceRepository(dbClient) - namespaceService := namespace.NewService(namespaceRepository) + namespaceService := namespace.NewService(namespaceRepository, userService, activityService) s := schema.NewSchemaMigrationService( blob.NewSchemaConfigRepository(resourceBlobFS), @@ -159,37 +166,40 @@ func BuildAPIDependencies( dbc *db.Client, sdb *spicedb.SpiceDB, ) (api.Deps, error) { + activityRepository := postgres.NewActivityRepository(dbc) + activityService := activity.NewService(activityRepository) + + userRepository := postgres.NewUserRepository(dbc) + userService := user.NewService(userRepository, activityService) + actionRepository := postgres.NewActionRepository(dbc) - actionService := action.NewService(actionRepository) + actionService := action.NewService(actionRepository, userService, activityService) namespaceRepository := postgres.NewNamespaceRepository(dbc) - namespaceService := namespace.NewService(namespaceRepository) - - userRepository := postgres.NewUserRepository(dbc) - userService := user.NewService(userRepository) + namespaceService := namespace.NewService(namespaceRepository, userService, activityService) roleRepository := postgres.NewRoleRepository(dbc) - roleService := role.NewService(roleRepository) + roleService := role.NewService(roleRepository, userService, activityService) relationPGRepository := postgres.NewRelationRepository(dbc) relationSpiceRepository := spicedb.NewRelationRepository(sdb) - relationService := relation.NewService(relationPGRepository, relationSpiceRepository) + relationService := relation.NewService(relationPGRepository, relationSpiceRepository, userService, activityService) groupRepository := postgres.NewGroupRepository(dbc) - groupService := group.NewService(groupRepository, relationService, userService) + groupService := group.NewService(groupRepository, relationService, userService, activityService) organizationRepository := postgres.NewOrganizationRepository(dbc) - organizationService := organization.NewService(organizationRepository, relationService, userService) + organizationService := organization.NewService(organizationRepository, relationService, userService, activityService) projectRepository := postgres.NewProjectRepository(dbc) - projectService := project.NewService(projectRepository, relationService, userService) + projectService := project.NewService(projectRepository, relationService, userService, activityService) policyPGRepository := postgres.NewPolicyRepository(dbc) - policyService := policy.NewService(policyPGRepository) + policyService := policy.NewService(policyPGRepository, userService, activityService) resourcePGRepository := postgres.NewResourceRepository(dbc) resourceService := resource.NewService( - resourcePGRepository, resourceBlobRepository, relationService, userService, projectService, organizationService, groupService) + resourcePGRepository, resourceBlobRepository, relationService, userService, projectService, organizationService, groupService, activityService) relationAdapter := adapter.NewRelation(groupService, userService, relationService) diff --git a/core/action/errors.go b/core/action/errors.go index 667f2f882..2e31c2378 100644 --- a/core/action/errors.go +++ b/core/action/errors.go @@ -6,4 +6,5 @@ var ( ErrInvalidID = errors.New("action id is invalid") ErrNotExist = errors.New("action doesn't exist") ErrInvalidDetail = errors.New("invalid action detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/action/service.go b/core/action/service.go index 09328b78a..3cde8d22e 100644 --- a/core/action/service.go +++ b/core/action/service.go @@ -2,15 +2,37 @@ package action import ( "context" + + "github.com/goto/shield/core/user" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyActionCreate = "action.create" + AuditKeyActionUpdate = "action.update" + + AuditEntity = "action" ) +type UserService interface { + FetchCurrentUser(ctx context.Context) (user.User, error) +} + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { - repository Repository + repository Repository + userService UserService + activityService ActivityService } -func NewService(repository Repository) *Service { +func NewService(repository Repository, userService UserService, activityService ActivityService) *Service { return &Service{ - repository: repository, + repository: repository, + userService: userService, + activityService: activityService, } } @@ -24,6 +46,18 @@ func (s Service) Create(ctx context.Context, action Action) (Action, error) { return Action{}, err } + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": newAction.ID, + "name": newAction.Name, + "namespaceId": newAction.NamespaceID, + } + if err := s.activityService.Log(ctx, AuditKeyActionCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newAction, nil } @@ -41,5 +75,17 @@ func (s Service) Update(ctx context.Context, id string, action Action) (Action, return Action{}, err } + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedAction.ID, + "name": updatedAction.Name, + "namespaceId": updatedAction.NamespaceID, + } + if err := s.activityService.Log(ctx, AuditKeyActionUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return updatedAction, nil } diff --git a/core/activity/activity.go b/core/activity/activity.go new file mode 100644 index 000000000..3b0d73f91 --- /dev/null +++ b/core/activity/activity.go @@ -0,0 +1,11 @@ +package activity + +import ( + "context" + + "github.com/goto/salt/audit" +) + +type Repository interface { + Insert(ctx context.Context, log *audit.Log) error +} diff --git a/core/activity/service.go b/core/activity/service.go new file mode 100644 index 000000000..ac9a37924 --- /dev/null +++ b/core/activity/service.go @@ -0,0 +1,36 @@ +package activity + +import ( + "context" + "time" + + "github.com/goto/salt/audit" + "github.com/goto/shield/config" +) + +type Service struct { + repository Repository +} + +func NewService(repository Repository) *Service { + return &Service{ + repository: repository, + } +} + +func (s Service) Log(ctx context.Context, action string, actor string, data map[string]string) error { + metadata := map[string]string{ + "app_name": "shield", + "app_version": config.Version, + } + + log := &audit.Log{ + Timestamp: time.Now(), + Action: action, + Data: data, + Actor: actor, + Metadata: metadata, + } + + return s.repository.Insert(ctx, log) +} diff --git a/core/group/errors.go b/core/group/errors.go index 892f4add5..629cfa745 100644 --- a/core/group/errors.go +++ b/core/group/errors.go @@ -11,4 +11,5 @@ var ( ErrListingGroupRelations = errors.New("error while listing relations") ErrFetchingUsers = errors.New("error while fetching users") ErrFetchingGroups = errors.New("error while fetching groups") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/group/service.go b/core/group/service.go index 8309ab13e..533bd6096 100644 --- a/core/group/service.go +++ b/core/group/service.go @@ -3,6 +3,7 @@ package group import ( "context" "fmt" + "maps" "strings" "github.com/goto/shield/core/action" @@ -12,6 +13,14 @@ import ( "github.com/goto/shield/internal/schema" "github.com/goto/shield/pkg/str" "github.com/goto/shield/pkg/uuid" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyGroupCreate = "group.create" + AuditKeyGroupUpdate = "group.update" + + AuditEntity = "group" ) type RelationService interface { @@ -26,22 +35,28 @@ type UserService interface { GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error) } +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { repository Repository relationService RelationService userService UserService + activityService ActivityService } -func NewService(repository Repository, relationService RelationService, userService UserService) *Service { +func NewService(repository Repository, relationService RelationService, userService UserService, activityService ActivityService) *Service { return &Service{ repository: repository, relationService: relationService, userService: userService, + activityService: activityService, } } func (s Service) Create(ctx context.Context, grp Group) (Group, error) { - _, err := s.userService.FetchCurrentUser(ctx) + currentUser, err := s.userService.FetchCurrentUser(ctx) if err != nil { return Group{}, fmt.Errorf("%w: %s", user.ErrInvalidEmail, err.Error()) } @@ -55,6 +70,19 @@ func (s Service) Create(ctx context.Context, grp Group) (Group, error) { return Group{}, err } + logData := map[string]string{ + "entity": AuditEntity, + "id": newGroup.ID, + "name": newGroup.Name, + "slug": newGroup.Slug, + "orgId": newGroup.OrganizationID, + } + maps.Copy(logData, newGroup.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyGroupCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newGroup, nil } @@ -81,7 +109,27 @@ func (s Service) Update(ctx context.Context, grp Group) (Group, error) { if strings.TrimSpace(grp.ID) != "" { return s.repository.UpdateByID(ctx, grp) } - return s.repository.UpdateBySlug(ctx, grp) + + updatedGroup, err := s.repository.UpdateBySlug(ctx, grp) + if err != nil { + return Group{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedGroup.ID, + "name": updatedGroup.Name, + "slug": updatedGroup.Slug, + "orgId": updatedGroup.ID, + } + maps.Copy(logData, updatedGroup.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyGroupUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return updatedGroup, nil } func (s Service) ListUserGroups(ctx context.Context, userId string, roleId string) ([]Group, error) { diff --git a/core/namespace/errors.go b/core/namespace/errors.go index f46996569..82754beb4 100644 --- a/core/namespace/errors.go +++ b/core/namespace/errors.go @@ -7,4 +7,5 @@ var ( ErrNotExist = errors.New("namespace doesn't exist") ErrConflict = errors.New("namespace name already exist") ErrInvalidDetail = errors.New("invalid namespace detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/namespace/service.go b/core/namespace/service.go index d12c6d667..518dfea27 100644 --- a/core/namespace/service.go +++ b/core/namespace/service.go @@ -2,15 +2,37 @@ package namespace import ( "context" + + "github.com/goto/shield/core/user" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" ) +const ( + AuditKeyNamespaceCreate = "namespace.create" + AuditKeyNamespaceUpdate = "namespace.update" + + AuditEntity = "namespace" +) + +type UserService interface { + FetchCurrentUser(ctx context.Context) (user.User, error) +} + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { - repository Repository + repository Repository + userService UserService + activityService ActivityService } -func NewService(repository Repository) *Service { +func NewService(repository Repository, userService UserService, activityService ActivityService) *Service { return &Service{ - repository: repository, + repository: repository, + userService: userService, + activityService: activityService, } } @@ -19,7 +41,25 @@ func (s Service) Get(ctx context.Context, id string) (Namespace, error) { } func (s Service) Create(ctx context.Context, ns Namespace) (Namespace, error) { - return s.repository.Create(ctx, ns) + newNamespace, err := s.repository.Create(ctx, ns) + if err != nil { + return Namespace{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": newNamespace.ID, + "name": newNamespace.Name, + "backend": newNamespace.Backend, + "resourceType": newNamespace.ResourceType, + } + if err := s.activityService.Log(ctx, AuditKeyNamespaceCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return newNamespace, nil } func (s Service) List(ctx context.Context) ([]Namespace, error) { @@ -31,5 +71,19 @@ func (s Service) Update(ctx context.Context, ns Namespace) (Namespace, error) { if err != nil { return Namespace{}, err } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedNamespace.ID, + "name": updatedNamespace.Name, + "backend": updatedNamespace.Backend, + "resourceType": updatedNamespace.ResourceType, + } + if err := s.activityService.Log(ctx, AuditKeyNamespaceUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return updatedNamespace, nil } diff --git a/core/organization/errors.go b/core/organization/errors.go index bea0f1581..f9e0ccb91 100644 --- a/core/organization/errors.go +++ b/core/organization/errors.go @@ -8,4 +8,5 @@ var ( ErrInvalidID = errors.New("org id is invalid") ErrConflict = errors.New("org already exist") ErrInvalidDetail = errors.New("invalid org detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/organization/service.go b/core/organization/service.go index ac3f74123..c878ac7aa 100644 --- a/core/organization/service.go +++ b/core/organization/service.go @@ -3,6 +3,7 @@ package organization import ( "context" "fmt" + "maps" "github.com/goto/shield/core/action" "github.com/goto/shield/core/namespace" @@ -10,6 +11,14 @@ import ( "github.com/goto/shield/core/user" "github.com/goto/shield/internal/schema" "github.com/goto/shield/pkg/uuid" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyOrganizationCreate = "organization.create" + AuditKeyOrganizationUpdate = "organization.update" + + AuditEntity = "organization" ) type RelationService interface { @@ -24,17 +33,23 @@ type UserService interface { GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error) } +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { repository Repository relationService RelationService userService UserService + activityService ActivityService } -func NewService(repository Repository, relationService RelationService, userService UserService) *Service { +func NewService(repository Repository, relationService RelationService, userService UserService, activityService ActivityService) *Service { return &Service{ repository: repository, relationService: relationService, userService: userService, + activityService: activityService, } } @@ -64,6 +79,18 @@ func (s Service) Create(ctx context.Context, org Organization) (Organization, er return Organization{}, err } + logData := map[string]string{ + "entity": AuditEntity, + "id": newOrg.ID, + "name": newOrg.Name, + "slug": newOrg.Slug, + } + maps.Copy(logData, newOrg.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyOrganizationCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newOrg, nil } @@ -75,7 +102,26 @@ func (s Service) Update(ctx context.Context, org Organization) (Organization, er if org.ID != "" { return s.repository.UpdateByID(ctx, org) } - return s.repository.UpdateBySlug(ctx, org) + + updatedOrg, err := s.repository.UpdateBySlug(ctx, org) + if err != nil { + return Organization{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedOrg.ID, + "name": updatedOrg.Name, + "slug": updatedOrg.Slug, + } + maps.Copy(logData, updatedOrg.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyOrganizationUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return updatedOrg, nil } func (s Service) ListAdmins(ctx context.Context, idOrSlug string) ([]user.User, error) { diff --git a/core/policy/errors.go b/core/policy/errors.go index e33649d08..8982719cb 100644 --- a/core/policy/errors.go +++ b/core/policy/errors.go @@ -8,4 +8,5 @@ var ( ErrInvalidID = errors.New("policy id is invalid") ErrConflict = errors.New("policy already exist") ErrInvalidDetail = errors.New("invalid policy detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/policy/service.go b/core/policy/service.go index c26f4da93..c4d9db1a9 100644 --- a/core/policy/service.go +++ b/core/policy/service.go @@ -2,15 +2,37 @@ package policy import ( "context" + + "github.com/goto/shield/core/user" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyPolicyCreate = "policy.create" + AuditKeyPolicyUpdate = "policy.update" + + AuditEntity = "policy" ) +type UserService interface { + FetchCurrentUser(ctx context.Context) (user.User, error) +} + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { - repository Repository + repository Repository + userService UserService + activityService ActivityService } -func NewService(repository Repository) *Service { +func NewService(repository Repository, userService UserService, activityService ActivityService) *Service { return &Service{ - repository: repository, + repository: repository, + userService: userService, + activityService: activityService, } } @@ -23,7 +45,8 @@ func (s Service) List(ctx context.Context) ([]Policy, error) { } func (s Service) Create(ctx context.Context, policy Policy) ([]Policy, error) { - if _, err := s.repository.Create(ctx, policy); err != nil { + policyId, err := s.repository.Create(ctx, policy) + if err != nil { return []Policy{}, err } policies, err := s.repository.List(ctx) @@ -31,11 +54,25 @@ func (s Service) Create(ctx context.Context, policy Policy) ([]Policy, error) { return []Policy{}, err } + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": policyId, + "roleId": policy.RoleID, + "namespaceId": policy.NamespaceID, + "actionId": policy.ActionID, + } + if err := s.activityService.Log(ctx, AuditKeyPolicyCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return policies, err } func (s Service) Update(ctx context.Context, pol Policy) ([]Policy, error) { - if _, err := s.repository.Update(ctx, pol); err != nil { + policyId, err := s.repository.Update(ctx, pol) + if err != nil { return []Policy{}, err } @@ -44,5 +81,18 @@ func (s Service) Update(ctx context.Context, pol Policy) ([]Policy, error) { return []Policy{}, err } + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": policyId, + "roleId": pol.RoleID, + "namespaceId": pol.NamespaceID, + "actionId": pol.ActionID, + } + if err := s.activityService.Log(ctx, AuditKeyPolicyUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return policies, err } diff --git a/core/project/errors.go b/core/project/errors.go index d4628f203..4e4e7c49f 100644 --- a/core/project/errors.go +++ b/core/project/errors.go @@ -8,4 +8,5 @@ var ( ErrInvalidID = errors.New("project id is invalid") ErrConflict = errors.New("project already exist") ErrInvalidDetail = errors.New("invalid project detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/project/service.go b/core/project/service.go index 80f17d2da..373733fba 100644 --- a/core/project/service.go +++ b/core/project/service.go @@ -3,6 +3,7 @@ package project import ( "context" "fmt" + "maps" "github.com/goto/shield/core/action" "github.com/goto/shield/core/namespace" @@ -11,6 +12,14 @@ import ( "github.com/goto/shield/core/user" "github.com/goto/shield/internal/schema" "github.com/goto/shield/pkg/uuid" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyProjectCreate = "project.create" + AuditKeyProjectUpdate = "project.update" + + AuditEntity = "project" ) type RelationService interface { @@ -25,17 +34,23 @@ type UserService interface { GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error) } +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { repository Repository relationService RelationService userService UserService + activityService ActivityService } -func NewService(repository Repository, relationService RelationService, userService UserService) *Service { +func NewService(repository Repository, relationService RelationService, userService UserService, activityService ActivityService) *Service { return &Service{ repository: repository, relationService: relationService, userService: userService, + activityService: activityService, } } @@ -47,7 +62,7 @@ func (s Service) Get(ctx context.Context, idOrSlug string) (Project, error) { } func (s Service) Create(ctx context.Context, prj Project) (Project, error) { - _, err := s.userService.FetchCurrentUser(ctx) + currentUser, err := s.userService.FetchCurrentUser(ctx) if err != nil { return Project{}, fmt.Errorf("%w: %s", user.ErrInvalidEmail, err.Error()) } @@ -66,6 +81,19 @@ func (s Service) Create(ctx context.Context, prj Project) (Project, error) { return Project{}, err } + logData := map[string]string{ + "entity": AuditEntity, + "id": newProject.ID, + "name": newProject.Name, + "slug": newProject.Slug, + "orgId": newProject.Organization.ID, + } + maps.Copy(logData, newProject.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyProjectCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newProject, nil } @@ -77,7 +105,27 @@ func (s Service) Update(ctx context.Context, prj Project) (Project, error) { if prj.ID != "" { return s.repository.UpdateByID(ctx, prj) } - return s.repository.UpdateBySlug(ctx, prj) + + updatedProject, err := s.repository.UpdateBySlug(ctx, prj) + if err != nil { + return Project{}, err + } + + logger := grpczap.Extract(ctx) + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedProject.ID, + "name": updatedProject.Name, + "slug": updatedProject.Slug, + "orgId": updatedProject.Organization.ID, + } + maps.Copy(logData, updatedProject.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyProjectUpdate, currentUser.ID, logData); err != nil { + logger.Error(ErrLogActivity.Error()) + } + + return updatedProject, err } func (s Service) AddAdmins(ctx context.Context, idOrSlug string, userIds []string) ([]user.User, error) { diff --git a/core/relation/errors.go b/core/relation/errors.go index 177eb20ac..e9ae1e230 100644 --- a/core/relation/errors.go +++ b/core/relation/errors.go @@ -12,4 +12,5 @@ var ( ErrCreatingRelationInAuthzEngine = errors.New("error while creating relation in authz engine") ErrFetchingUser = errors.New("error while fetching user") ErrFetchingGroup = errors.New("error while fetching group") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/relation/service.go b/core/relation/service.go index a0a422e6e..4b3db248b 100644 --- a/core/relation/service.go +++ b/core/relation/service.go @@ -7,17 +7,39 @@ import ( "github.com/goto/shield/core/action" "github.com/goto/shield/core/namespace" "github.com/goto/shield/core/user" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" ) +const ( + AuditKeyRelationCreate = "relation.create" + AuditKeyRelationDelete = "relation.delete" + AuditKeyRelationSubjectDelete = "relation_subject.delete" + + AuditEntityRelation = "relation" + AuditEntityRelationSubject = "relation_subject" +) + +type UserService interface { + FetchCurrentUser(ctx context.Context) (user.User, error) +} + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { repository Repository authzRepository AuthzRepository + userService UserService + activityService ActivityService } -func NewService(repository Repository, authzRepository AuthzRepository) *Service { +func NewService(repository Repository, authzRepository AuthzRepository, userService UserService, activityService ActivityService) *Service { return &Service{ repository: repository, authzRepository: authzRepository, + userService: userService, + activityService: activityService, } } @@ -36,6 +58,21 @@ func (s Service) Create(ctx context.Context, rel RelationV2) (RelationV2, error) return RelationV2{}, fmt.Errorf("%w: %s", ErrCreatingRelationInAuthzEngine, err.Error()) } + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityRelation, + "id": createdRelation.ID, + "objectId": createdRelation.Object.ID, + "objectNamespaceId": createdRelation.Object.NamespaceID, + "subjectId": createdRelation.Subject.ID, + "subjectNamespace": createdRelation.Subject.Namespace, + "roleId": createdRelation.Subject.RoleID, + } + if err := s.activityService.Log(ctx, AuditKeyRelationCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return createdRelation, nil } @@ -44,6 +81,7 @@ func (s Service) List(ctx context.Context) ([]RelationV2, error) { } // TODO: Update & Delete planned for v0.6 +// TODO: Audit log func (s Service) Update(ctx context.Context, toUpdate Relation) (Relation, error) { //oldRelation, err := s.repository.Get(ctx, toUpdate.ID) //if err != nil { @@ -112,5 +150,21 @@ func (s Service) CheckPermission(ctx context.Context, usr user.User, resourceNS } func (s Service) DeleteSubjectRelations(ctx context.Context, resourceType, optionalResourceID string) error { - return s.authzRepository.DeleteSubjectRelations(ctx, resourceType, optionalResourceID) + err := s.authzRepository.DeleteSubjectRelations(ctx, resourceType, optionalResourceID) + if err != nil { + return err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityRelationSubject, + "resourceType": resourceType, + "optionalResourceId": optionalResourceID, + } + if err := s.activityService.Log(ctx, AuditKeyRelationCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return nil } diff --git a/core/resource/errors.go b/core/resource/errors.go index 83ca7204f..375d3b4c0 100644 --- a/core/resource/errors.go +++ b/core/resource/errors.go @@ -9,4 +9,5 @@ var ( ErrInvalidURN = errors.New("resource urn is invalid") ErrConflict = errors.New("resource already exist") ErrInvalidDetail = errors.New("invalid resource detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/resource/service.go b/core/resource/service.go index e17cb2d48..edc82ca7a 100644 --- a/core/resource/service.go +++ b/core/resource/service.go @@ -13,6 +13,14 @@ import ( "github.com/goto/shield/core/user" "github.com/goto/shield/internal/schema" "github.com/goto/shield/pkg/uuid" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyResourceCreate = "resource.create" + AuditKeyResourceUpdate = "resource.update" + + AuditEntity = "resource" ) type RelationService interface { @@ -38,6 +46,10 @@ type GroupService interface { Get(ctx context.Context, id string) (group.Group, error) } +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { repository Repository configRepository ConfigRepository @@ -46,9 +58,10 @@ type Service struct { projectService ProjectService organizationService OrganizationService groupService GroupService + activityService ActivityService } -func NewService(repository Repository, configRepository ConfigRepository, relationService RelationService, userService UserService, projectService ProjectService, organizationService OrganizationService, groupService GroupService) *Service { +func NewService(repository Repository, configRepository ConfigRepository, relationService RelationService, userService UserService, projectService ProjectService, organizationService OrganizationService, groupService GroupService, activityService ActivityService) *Service { return &Service{ repository: repository, configRepository: configRepository, @@ -57,6 +70,7 @@ func NewService(repository Repository, configRepository ConfigRepository, relati projectService: projectService, organizationService: organizationService, groupService: groupService, + activityService: activityService, } } @@ -106,6 +120,20 @@ func (s Service) Create(ctx context.Context, res Resource) (Resource, error) { return Resource{}, err } + logData := map[string]string{ + "entity": AuditEntity, + "urn": newResource.URN, + "name": newResource.Name, + "organizationID": newResource.OrganizationID, + "projectID": newResource.ProjectID, + "namespaceID": newResource.NamespaceID, + "userID": newResource.UserID, + } + if err := s.activityService.Log(ctx, AuditKeyResourceCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newResource, nil } @@ -122,7 +150,27 @@ func (s Service) List(ctx context.Context, flt Filter) (PagedResources, error) { func (s Service) Update(ctx context.Context, id string, resource Resource) (Resource, error) { // TODO there should be an update logic like create here - return s.repository.Update(ctx, id, resource) + updatedResource, err := s.repository.Update(ctx, id, resource) + if err != nil { + return Resource{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "urn": updatedResource.URN, + "name": updatedResource.Name, + "organizationID": updatedResource.OrganizationID, + "projectID": updatedResource.ProjectID, + "namespaceID": updatedResource.NamespaceID, + "userID": updatedResource.UserID, + } + if err := s.activityService.Log(ctx, AuditKeyResourceUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return updatedResource, nil } func (s Service) AddProjectToResource(ctx context.Context, project project.Project, res Resource) error { diff --git a/core/role/errors.go b/core/role/errors.go index f68b2373c..3b1df081c 100644 --- a/core/role/errors.go +++ b/core/role/errors.go @@ -7,4 +7,5 @@ var ( ErrInvalidID = errors.New("role id is invalid") ErrConflict = errors.New("role name already exist") ErrInvalidDetail = errors.New("invalid role detail") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/role/service.go b/core/role/service.go index 7fc900152..32654566c 100644 --- a/core/role/service.go +++ b/core/role/service.go @@ -2,15 +2,38 @@ package role import ( "context" + "strings" + + "github.com/goto/shield/core/user" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +const ( + AuditKeyRoleCreate = "role.create" + AuditKeyRoleUpdate = "role.update" + + AuditEntity = "role" ) +type UserService interface { + FetchCurrentUser(ctx context.Context) (user.User, error) +} + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { - repository Repository + repository Repository + userService UserService + activityService ActivityService } -func NewService(repository Repository) *Service { +func NewService(repository Repository, userService UserService, activityService ActivityService) *Service { return &Service{ - repository: repository, + repository: repository, + userService: userService, + activityService: activityService, } } @@ -19,7 +42,26 @@ func (s Service) Create(ctx context.Context, toCreate Role) (Role, error) { if err != nil { return Role{}, err } - return s.repository.Get(ctx, roleID) + + newRole, err := s.repository.Get(ctx, roleID) + if err != nil { + return Role{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": newRole.ID, + "name": newRole.Name, + "types": strings.Join(newRole.Types, " "), + "namespaceId": newRole.NamespaceID, + } + if err := s.activityService.Log(ctx, AuditKeyRoleCreate, currentUser.Email, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return newRole, nil } func (s Service) Get(ctx context.Context, id string) (Role, error) { @@ -35,5 +77,24 @@ func (s Service) Update(ctx context.Context, toUpdate Role) (Role, error) { if err != nil { return Role{}, err } - return s.repository.Get(ctx, roleID) + + updatedRole, err := s.repository.Get(ctx, roleID) + if err != nil { + return Role{}, err + } + + currentUser, _ := s.userService.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntity, + "id": updatedRole.ID, + "name": updatedRole.Name, + "types": strings.Join(updatedRole.Types, " "), + "namespaceId": updatedRole.NamespaceID, + } + if err := s.activityService.Log(ctx, AuditKeyRoleCreate, currentUser.Email, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return updatedRole, nil } diff --git a/core/user/errors.go b/core/user/errors.go index 1f8dfb4e2..9d074eeb0 100644 --- a/core/user/errors.go +++ b/core/user/errors.go @@ -12,4 +12,5 @@ var ( ErrKeyDoesNotExists = errors.New("key does not exist") ErrMissingEmail = errors.New("user email is missing") ErrInvalidUUID = errors.New("invalid syntax of uuid") + ErrLogActivity = errors.New("error while logging activity") ) diff --git a/core/user/service.go b/core/user/service.go index 2876735b2..f567bf35b 100644 --- a/core/user/service.go +++ b/core/user/service.go @@ -2,18 +2,35 @@ package user import ( "context" + "maps" "strings" "github.com/goto/shield/pkg/uuid" + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" ) +const ( + AuditKeyUserCreate = "user.create" + AuditKeyUserUpdate = "user.update" + AuditKeyUserMetadataKeyCreate = "user_metadata_key.create" + + AuditEntityUser = "user" + AuditEntityUserMetadata = "user_metadata_key" +) + +type ActivityService interface { + Log(ctx context.Context, action string, actor string, data map[string]string) error +} + type Service struct { - repository Repository + repository Repository + activityService ActivityService } -func NewService(repository Repository) *Service { +func NewService(repository Repository, activityService ActivityService) *Service { return &Service{ - repository: repository, + repository: repository, + activityService: activityService, } } @@ -46,6 +63,18 @@ func (s Service) Create(ctx context.Context, user User) (User, error) { return User{}, err } + currentUser, _ := s.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityUser, + "name": newUser.Name, + "email": newUser.Email, + } + maps.Copy(logData, newUser.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyUserCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newUser, nil } @@ -58,6 +87,17 @@ func (s Service) CreateMetadataKey(ctx context.Context, key UserMetadataKey) (Us return UserMetadataKey{}, err } + currentUser, _ := s.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityUserMetadata, + "key": newUserMetadataKey.Key, + "description": newUserMetadataKey.Description, + } + if err := s.activityService.Log(ctx, AuditKeyUserMetadataKeyCreate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + return newUserMetadataKey, nil } @@ -74,11 +114,45 @@ func (s Service) List(ctx context.Context, flt Filter) (PagedUsers, error) { } func (s Service) UpdateByID(ctx context.Context, toUpdate User) (User, error) { - return s.repository.UpdateByID(ctx, toUpdate) + updatedUser, err := s.repository.UpdateByID(ctx, toUpdate) + if err != nil { + return User{}, err + } + + currentUser, _ := s.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityUser, + "name": updatedUser.Name, + "email": updatedUser.Email, + } + maps.Copy(logData, updatedUser.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyUserUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(err.Error()) + } + + return updatedUser, nil } func (s Service) UpdateByEmail(ctx context.Context, toUpdate User) (User, error) { - return s.repository.UpdateByEmail(ctx, toUpdate) + updatedUser, err := s.repository.UpdateByEmail(ctx, toUpdate) + if err != nil { + return User{}, err + } + + currentUser, _ := s.FetchCurrentUser(ctx) + logData := map[string]string{ + "entity": AuditEntityUser, + "name": updatedUser.Name, + "email": updatedUser.Email, + } + maps.Copy(logData, updatedUser.Metadata.ToStringValueMap()) + if err := s.activityService.Log(ctx, AuditKeyUserUpdate, currentUser.ID, logData); err != nil { + logger := grpczap.Extract(ctx) + logger.Error(ErrLogActivity.Error()) + } + + return updatedUser, nil } func (s Service) FetchCurrentUser(ctx context.Context) (User, error) { diff --git a/internal/store/postgres/activity.go b/internal/store/postgres/activity.go new file mode 100644 index 000000000..fc5642b58 --- /dev/null +++ b/internal/store/postgres/activity.go @@ -0,0 +1,11 @@ +package postgres + +import "time" + +type Activity struct { + Actor string `db:"actor"` + Action string `db:"action"` + Data map[string]string `db:"data"` + Metadata map[string]string `db:"metadata"` + Timestamp time.Time `db:"timestamp"` +} diff --git a/internal/store/postgres/activity_repository.go b/internal/store/postgres/activity_repository.go new file mode 100644 index 000000000..e0e93399b --- /dev/null +++ b/internal/store/postgres/activity_repository.go @@ -0,0 +1,66 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/doug-martin/goqu/v9" + "github.com/goto/salt/audit" + "github.com/goto/shield/pkg/db" + newrelic "github.com/newrelic/go-agent" +) + +type ActivityRepository struct { + dbc *db.Client +} + +func NewActivityRepository(dbc *db.Client) *ActivityRepository { + return &ActivityRepository{ + dbc: dbc, + } +} + +func (r ActivityRepository) Insert(ctx context.Context, log *audit.Log) error { + marshaledMetadata, err := json.Marshal(log.Metadata) + if err != nil { + return fmt.Errorf("%w: %s", parseErr, err) + } + + marshaledData, err := json.Marshal(log.Data) + if err != nil { + return fmt.Errorf("%w: %s", parseErr, err) + } + + query, params, err := dialect.Insert(TABLE_ACTIVITY).Rows( + goqu.Record{ + "actor": log.Actor, + "action": log.Action, + "data": marshaledData, + "metadata": marshaledMetadata, + "timestamp": log.Timestamp, + }).ToSQL() + if err != nil { + return fmt.Errorf("%w: %s", queryErr, err) + } + + if err = r.dbc.WithTimeout(ctx, func(ctx context.Context) error { + nrCtx := newrelic.FromContext(ctx) + if nrCtx != nil { + nr := newrelic.DatastoreSegment{ + Product: newrelic.DatastorePostgres, + Collection: TABLE_ACTIVITY, + Operation: "Create", + StartTime: nrCtx.StartSegmentNow(), + } + defer nr.End() + } + _, err = r.dbc.ExecContext(ctx, query, params...) + return err + }); err != nil { + err = checkPostgresError(err) + return err + } + + return nil +} diff --git a/internal/store/postgres/migrations/20240402042520_create_activity_table.down.sql b/internal/store/postgres/migrations/20240402042520_create_activity_table.down.sql new file mode 100644 index 000000000..57b5c0fc9 --- /dev/null +++ b/internal/store/postgres/migrations/20240402042520_create_activity_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS activities; \ No newline at end of file diff --git a/internal/store/postgres/migrations/20240402042520_create_activity_table.up.sql b/internal/store/postgres/migrations/20240402042520_create_activity_table.up.sql new file mode 100644 index 000000000..956e1e1cc --- /dev/null +++ b/internal/store/postgres/migrations/20240402042520_create_activity_table.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS activities ( + timestamp timestamptz NOT NULL, + action varchar NOT NULL, + actor varchar, + data jsonb NOT NULL, + metadata jsonb NOT NULL +); + +CREATE INDEX activity_timestamp_index ON activities (timestamp); +CREATE INDEX activity_action_index ON activities (action); +CREATE INDEX activity_actor_index ON activities (actor); +CREATE INDEX activity_data_index ON activities (data); +CREATE INDEX activity_metadata_index ON activities (metadata); \ No newline at end of file diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index 631025f98..0294b08ba 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -32,6 +32,7 @@ const ( TABLE_USERS = "users" TABLE_METADATA = "metadata" TABLE_METADATA_KEYS = "metadata_keys" + TABLE_ACTIVITY = "activities" ) func checkPostgresError(err error) error { diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go index 47f16f1ff..a314c9a73 100644 --- a/pkg/metadata/metadata.go +++ b/pkg/metadata/metadata.go @@ -22,6 +22,16 @@ func (m Metadata) ToStructPB() (*structpb.Struct, error) { return structpb.NewStruct(newMap) } +func (m Metadata) ToStringValueMap() map[string]string { + newMap := make(map[string]string) + + for key, value := range m { + newMap[key] = value.(string) + } + + return newMap +} + // Build transforms a Metadata from map[string]interface{} func Build(m map[string]interface{}) (Metadata, error) { newMap := make(Metadata)