diff --git a/audit/pkg/service/service.go b/audit/pkg/service/service.go index f2f09766a..adf16f291 100644 --- a/audit/pkg/service/service.go +++ b/audit/pkg/service/service.go @@ -85,6 +85,20 @@ func StartAuditLogger(ctx context.Context, ch <-chan interface{}, log log.Logger auditEvent = types.SpaceEnabled(ev) case events.SpaceDeleted: auditEvent = types.SpaceDeleted(ev) + case events.UserCreated: + auditEvent = types.UserCreated(ev) + case events.UserDeleted: + auditEvent = types.UserDeleted(ev) + case events.UserFeatureChanged: + auditEvent = types.UserFeatureChanged(ev) + case events.GroupCreated: + auditEvent = types.GroupCreated(ev) + case events.GroupDeleted: + auditEvent = types.GroupDeleted(ev) + case events.GroupMemberAdded: + auditEvent = types.GroupMemberAdded(ev) + case events.GroupMemberRemoved: + auditEvent = types.GroupMemberRemoved(ev) default: log.Error().Interface("event", ev).Msg(fmt.Sprintf("can't handle event of type '%T'", ev)) continue diff --git a/audit/pkg/types/constants.go b/audit/pkg/types/constants.go index 4f2f79d5e..a6bdfd119 100644 --- a/audit/pkg/types/constants.go +++ b/audit/pkg/types/constants.go @@ -1,6 +1,11 @@ package types -import "fmt" +import ( + "fmt" + "strings" + + "github.com/cs3org/reva/v2/pkg/events" +) // short identifiers for audit actions const ( @@ -30,6 +35,17 @@ const ( ActionSpaceDisabled = "space_disabled" ActionSpaceEnabled = "space_enabled" ActionSpaceDeleted = "space_deleted" + + // Users + ActionUserCreated = "user_created" + ActionUserDeleted = "user_deleted" + ActionUserFeatureChanged = "user_feature_changed" + + // Groups + ActionGroupCreated = "group_created" + ActionGroupDeleted = "group_deleted" + ActionGroupMemberAdded = "group_member_added" + ActionGroupMemberRemoved = "group_member_removed" ) // MessageShareCreated returns the human readable string that describes the action @@ -136,3 +152,49 @@ func MessageSpaceEnabled(spaceID string) string { func MessageSpaceDeleted(spaceID string) string { return fmt.Sprintf("Space '%s' was deleted", spaceID) } + +// MessageUserCreated returns the human readable string that describes the action +func MessageUserCreated(userID string) string { + return fmt.Sprintf("User '%s' was created", userID) +} + +// MessageUserDeleted returns the human readable string that describes the action +func MessageUserDeleted(userID string) string { + return fmt.Sprintf("User '%s' was deleted", userID) +} + +// MessageUserFeatureChanged returns the human readable string that describes the action +func MessageUserFeatureChanged(userID string, features []events.UserFeature) string { + // Result is: "User %username%'s feature changed: %featurename%=%featurevalue% %featurename%=%featurevalue%" + var sb strings.Builder + sb.WriteString("User ") + sb.WriteString(userID) + sb.WriteString("'s feature changed: ") + for _, f := range features { + sb.WriteString(f.Name) + sb.WriteRune('=') + sb.WriteString(f.Value) + sb.WriteRune(' ') + } + return sb.String() +} + +// MessageGroupCreated returns the human readable string that describes the action +func MessageGroupCreated(groupID string) string { + return fmt.Sprintf("Group '%s' was created", groupID) +} + +// MessageGroupDeleted returns the human readable string that describes the action +func MessageGroupDeleted(groupID string) string { + return fmt.Sprintf("Group '%s' was deleted", groupID) +} + +// MessageGroupMemberAdded returns the human readable string that describes the action +func MessageGroupMemberAdded(userID, groupID string) string { + return fmt.Sprintf("User '%s' was added to group '%s'", userID, groupID) +} + +// MessageGroupMemberRemoved returns the human readable string that describes the action +func MessageGroupMemberRemoved(userID, groupID string) string { + return fmt.Sprintf("User '%s' was removed from group '%s'", userID, groupID) +} diff --git a/audit/pkg/types/conversion.go b/audit/pkg/types/conversion.go index f03732daf..5c3f3f1c8 100644 --- a/audit/pkg/types/conversion.go +++ b/audit/pkg/types/conversion.go @@ -369,6 +369,75 @@ func SpaceDeleted(ev events.SpaceDeleted) AuditEventSpaceDeleted { } } +// UserCreated converts a UserCreated event to an AuditEventUserCreated +func UserCreated(ev events.UserCreated) AuditEventUserCreated { + base := BasicAuditEvent("", "", MessageUserCreated(ev.UserID), ActionUserCreated) + return AuditEventUserCreated{ + AuditEvent: base, + UserID: ev.UserID, + } +} + +// UserDeleted converts a UserDeleted event to an AuditEventUserDeleted +func UserDeleted(ev events.UserDeleted) AuditEventUserDeleted { + base := BasicAuditEvent("", "", MessageUserDeleted(ev.UserID), ActionUserDeleted) + return AuditEventUserDeleted{ + AuditEvent: base, + UserID: ev.UserID, + } +} + +// UserFeatureChanged converts a UserFeatureChanged event to an AuditEventUserFeatureChanged +func UserFeatureChanged(ev events.UserFeatureChanged) AuditEventUserFeatureChanged { + msg := MessageUserFeatureChanged(ev.UserID, ev.Features) + base := BasicAuditEvent("", "", msg, ActionUserFeatureChanged) + return AuditEventUserFeatureChanged{ + AuditEvent: base, + UserID: ev.UserID, + Features: ev.Features, + } +} + +// GroupCreated converts a GroupCreated event to an AuditEventGroupCreated +func GroupCreated(ev events.GroupCreated) AuditEventGroupCreated { + base := BasicAuditEvent("", "", MessageGroupCreated(ev.GroupID), ActionGroupCreated) + return AuditEventGroupCreated{ + AuditEvent: base, + GroupID: ev.GroupID, + } +} + +// GroupDeleted converts a GroupDeleted event to an AuditEventGroupDeleted +func GroupDeleted(ev events.GroupDeleted) AuditEventGroupDeleted { + base := BasicAuditEvent("", "", MessageGroupDeleted(ev.GroupID), ActionGroupDeleted) + return AuditEventGroupDeleted{ + AuditEvent: base, + GroupID: ev.GroupID, + } +} + +// GroupMemberAdded converts a GroupMemberAdded event to an AuditEventGroupMemberAdded +func GroupMemberAdded(ev events.GroupMemberAdded) AuditEventGroupMemberAdded { + msg := MessageGroupMemberAdded(ev.GroupID, ev.UserID) + base := BasicAuditEvent("", "", msg, ActionGroupMemberAdded) + return AuditEventGroupMemberAdded{ + AuditEvent: base, + GroupID: ev.GroupID, + UserID: ev.UserID, + } +} + +// GroupMemberRemoved converts a GroupMemberRemoved event to an AuditEventGroupMemberRemove +func GroupMemberRemoved(ev events.GroupMemberRemoved) AuditEventGroupMemberRemoved { + msg := MessageGroupMemberRemoved(ev.GroupID, ev.UserID) + base := BasicAuditEvent("", "", msg, ActionGroupMemberRemoved) + return AuditEventGroupMemberRemoved{ + AuditEvent: base, + GroupID: ev.GroupID, + UserID: ev.UserID, + } +} + func extractGrantee(uid *user.UserId, gid *group.GroupId) (string, string) { switch { case uid != nil && uid.OpaqueId != "": diff --git a/audit/pkg/types/events.go b/audit/pkg/types/events.go index 743044a04..1862b1f80 100644 --- a/audit/pkg/types/events.go +++ b/audit/pkg/types/events.go @@ -28,5 +28,12 @@ func RegisteredEvents() []events.Unmarshaller { events.SpaceEnabled{}, events.SpaceDisabled{}, events.SpaceDeleted{}, + events.UserCreated{}, + events.UserDeleted{}, + events.UserFeatureChanged{}, + events.GroupCreated{}, + events.GroupDeleted{}, + events.GroupMemberAdded{}, + events.GroupMemberRemoved{}, } } diff --git a/audit/pkg/types/types.go b/audit/pkg/types/types.go index d4227a196..903ca2345 100644 --- a/audit/pkg/types/types.go +++ b/audit/pkg/types/types.go @@ -1,5 +1,7 @@ package types +import "github.com/cs3org/reva/v2/pkg/events" + // AuditEvent is the basic audit event type AuditEvent struct { RemoteAddr string // the remote client IP @@ -197,3 +199,48 @@ type AuditEventSpaceEnabled struct { type AuditEventSpaceDeleted struct { AuditEventSpaces } + +// AuditEventUserCreated is the event logged when a user is created +type AuditEventUserCreated struct { + AuditEvent + UserID string +} + +// AuditEventUserDeleted is the event logged when a user is deleted +type AuditEventUserDeleted struct { + AuditEvent + UserID string +} + +// AuditEventUserFeatureChanged is the event logged when a user feature is changed +type AuditEventUserFeatureChanged struct { + AuditEvent + UserID string + Features []events.UserFeature +} + +// AuditEventGroupCreated is the event logged when a group is created +type AuditEventGroupCreated struct { + AuditEvent + GroupID string +} + +// AuditEventGroupDeleted is the event logged when a group is deleted +type AuditEventGroupDeleted struct { + AuditEvent + GroupID string +} + +// AuditEventGroupMemberAdded is the event logged when a group member is added +type AuditEventGroupMemberAdded struct { + AuditEvent + GroupID string + UserID string +} + +// AuditEventGroupMemberRemoved is the event logged when a group member is removed +type AuditEventGroupMemberRemoved struct { + AuditEvent + GroupID string + UserID string +} diff --git a/changelog/unreleased/user-group-audit.md b/changelog/unreleased/user-group-audit.md new file mode 100644 index 000000000..ff3f6310a --- /dev/null +++ b/changelog/unreleased/user-group-audit.md @@ -0,0 +1,12 @@ +Enhancement: Implement audit events for user and groups + +Added audit events for users and groups. This will log: +* User creation +* User deletion +* User property change (currently only email) +* Group creation +* Group deletion +* Group member add +* Group member remove + +https://github.com/owncloud/ocis/pull/3467 diff --git a/graph/Makefile b/graph/Makefile index e94f74f4b..40bb0caca 100644 --- a/graph/Makefile +++ b/graph/Makefile @@ -27,6 +27,8 @@ include ../.make/generate.mk ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target $(MOCKERY) --dir pkg/service/v0 --case underscore --name GatewayClient $(MOCKERY) --dir pkg/service/v0 --case underscore --name HTTPClient + $(MOCKERY) --dir pkg/service/v0 --case underscore --name Publisher + .PHONY: ci-node-generate ci-node-generate: diff --git a/graph/mocks/publisher.go b/graph/mocks/publisher.go new file mode 100644 index 000000000..88c802e6d --- /dev/null +++ b/graph/mocks/publisher.go @@ -0,0 +1,34 @@ +// Code generated by mockery v2.9.4. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + events "go-micro.dev/v4/events" +) + +// Publisher is an autogenerated mock type for the Publisher type +type Publisher struct { + mock.Mock +} + +// Publish provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Publisher) Publish(_a0 string, _a1 interface{}, _a2 ...events.PublishOption) error { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}, ...events.PublishOption) error); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/graph/pkg/config/config.go b/graph/pkg/config/config.go index 3501a30a0..fe59ea690 100644 --- a/graph/pkg/config/config.go +++ b/graph/pkg/config/config.go @@ -23,6 +23,7 @@ type Config struct { Spaces Spaces `yaml:"spaces"` Identity Identity `yaml:"identity"` + Events Events `yaml:"events"` Context context.Context `yaml:"-"` } @@ -62,3 +63,9 @@ type Identity struct { Backend string `yaml:"backend" env:"GRAPH_IDENTITY_BACKEND"` LDAP LDAP `yaml:"ldap"` } + +// Events combines the configuration options for the event bus. +type Events struct { + Endpoint string `yaml:"events_endpoint" env:"GRAPH_EVENTS_ENDPOINT" desc:"the address of the streaming service"` + Cluster string `yaml:"events_cluster" env:"GRAPH_EVENTS_CLUSTER" desc:"the clusterID of the streaming service. Mandatory when using nats"` +} diff --git a/graph/pkg/config/defaults/defaultconfig.go b/graph/pkg/config/defaults/defaultconfig.go index 9c2eba50c..5bab47e89 100644 --- a/graph/pkg/config/defaults/defaultconfig.go +++ b/graph/pkg/config/defaults/defaultconfig.go @@ -57,6 +57,10 @@ func DefaultConfig() *config.Config { GroupIDAttribute: "owncloudUUID", }, }, + Events: config.Events{ + Endpoint: "127.0.0.1:9233", + Cluster: "ocis-cluster", + }, } } diff --git a/graph/pkg/server/http/server.go b/graph/pkg/server/http/server.go index 36f64ef3f..0456e1bcb 100644 --- a/graph/pkg/server/http/server.go +++ b/graph/pkg/server/http/server.go @@ -1,6 +1,8 @@ package http import ( + "github.com/asim/go-micro/plugins/events/natsjs/v4" + "github.com/cs3org/reva/v2/pkg/events/server" chimiddleware "github.com/go-chi/chi/v5/middleware" graphMiddleware "github.com/owncloud/ocis/graph/pkg/middleware" svc "github.com/owncloud/ocis/graph/pkg/service/v0" @@ -8,6 +10,7 @@ import ( "github.com/owncloud/ocis/ocis-pkg/middleware" "github.com/owncloud/ocis/ocis-pkg/service/http" "github.com/owncloud/ocis/ocis-pkg/version" + "github.com/pkg/errors" "go-micro.dev/v4" ) @@ -25,6 +28,17 @@ func Server(opts ...Option) (http.Service, error) { http.Flags(options.Flags...), ) + publisher, err := server.NewNatsStream( + natsjs.Address(options.Config.Events.Endpoint), + natsjs.ClusterID(options.Config.Events.Cluster), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing events publisher") + return http.Service{}, errors.Wrap(err, "could not initialize events publisher") + } + handle := svc.NewService( svc.Logger(options.Logger), svc.Config(options.Config), @@ -42,6 +56,7 @@ func Server(opts ...Option) (http.Service, error) { account.JWTSecret(options.Config.TokenManager.JWTSecret), ), ), + svc.EventsPublisher(publisher), ) { diff --git a/graph/pkg/service/v0/graph.go b/graph/pkg/service/v0/graph.go index 2dd618d09..3d2ef13b8 100644 --- a/graph/pkg/service/v0/graph.go +++ b/graph/pkg/service/v0/graph.go @@ -7,11 +7,13 @@ import ( "github.com/ReneKroon/ttlcache/v2" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/v2/pkg/events" "github.com/go-chi/chi/v5" "github.com/owncloud/ocis/graph/pkg/config" "github.com/owncloud/ocis/graph/pkg/identity" "github.com/owncloud/ocis/ocis-pkg/log" settingssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/settings/v0" + mevents "go-micro.dev/v4/events" "google.golang.org/grpc" ) @@ -51,6 +53,11 @@ type GatewayClient interface { GetQuota(ctx context.Context, in *gateway.GetQuotaRequest, opts ...grpc.CallOption) (*provider.GetQuotaResponse, error) } +// Publisher is the interface for events publisher +type Publisher interface { + Publish(string, interface{}, ...mevents.PublishOption) error +} + // HTTPClient is the subset of the http.Client that is being used to interact with the download gateway type HTTPClient interface { Do(req *http.Request) (*http.Response, error) @@ -69,6 +76,7 @@ type Graph struct { httpClient HTTPClient roleService settingssvc.RoleService spacePropertiesCache *ttlcache.Cache + eventsPublisher events.Publisher } // ServeHTTP implements the Service interface. @@ -86,6 +94,14 @@ func (g Graph) GetHTTPClient() HTTPClient { return g.httpClient } +func (g Graph) publishEvent(ev interface{}) { + if err := events.Publish(g.eventsPublisher, ev); err != nil { + g.logger.Error(). + Err(err). + Msg("could not publish user created event") + } +} + type listResponse struct { Value interface{} `json:"value,omitempty"` } diff --git a/graph/pkg/service/v0/graph_test.go b/graph/pkg/service/v0/graph_test.go index bd07a5cde..e63ca3d4e 100644 --- a/graph/pkg/service/v0/graph_test.go +++ b/graph/pkg/service/v0/graph_test.go @@ -25,20 +25,23 @@ import ( var _ = Describe("Graph", func() { var ( - svc service.Service - gatewayClient *mocks.GatewayClient - httpClient *mocks.HTTPClient - ctx context.Context + svc service.Service + gatewayClient *mocks.GatewayClient + httpClient *mocks.HTTPClient + eventsPublisher mocks.Publisher + ctx context.Context ) JustBeforeEach(func() { ctx = context.Background() gatewayClient = &mocks.GatewayClient{} httpClient = &mocks.HTTPClient{} + eventsPublisher = mocks.Publisher{} svc = service.NewService( service.Config(defaults.DefaultConfig()), service.WithGatewayClient(gatewayClient), service.WithHTTPClient(httpClient), + service.EventsPublisher(&eventsPublisher), ) }) diff --git a/graph/pkg/service/v0/groups.go b/graph/pkg/service/v0/groups.go index 0baa90759..9e53b8cfd 100644 --- a/graph/pkg/service/v0/groups.go +++ b/graph/pkg/service/v0/groups.go @@ -13,6 +13,7 @@ import ( libregraph "github.com/owncloud/libre-graph-api-go" "github.com/owncloud/ocis/graph/pkg/service/v0/errorcode" + "github.com/cs3org/reva/v2/pkg/events" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) @@ -81,6 +82,9 @@ func (g Graph) PostGroup(w http.ResponseWriter, r *http.Request) { return } + if grp != nil && grp.Id != nil { + g.publishEvent(events.GroupCreated{GroupID: *grp.Id}) + } render.Status(r, http.StatusOK) render.JSON(w, r, grp) } @@ -197,6 +201,8 @@ func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) { } return } + + g.publishEvent(events.GroupDeleted{GroupID: groupID}) render.Status(r, http.StatusNoContent) render.NoContent(w, r) } @@ -279,6 +285,8 @@ func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) { } return } + + g.publishEvent(events.GroupMemberAdded{GroupID: groupID, UserID: id}) render.Status(r, http.StatusNoContent) render.NoContent(w, r) } @@ -322,6 +330,7 @@ func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) { } return } + g.publishEvent(events.GroupMemberRemoved{GroupID: groupID, UserID: memberID}) render.Status(r, http.StatusNoContent) render.NoContent(w, r) } diff --git a/graph/pkg/service/v0/option.go b/graph/pkg/service/v0/option.go index cd1904877..39c0765ce 100644 --- a/graph/pkg/service/v0/option.go +++ b/graph/pkg/service/v0/option.go @@ -3,6 +3,7 @@ package svc import ( "net/http" + "github.com/cs3org/reva/v2/pkg/events" "github.com/owncloud/ocis/graph/pkg/config" "github.com/owncloud/ocis/ocis-pkg/log" "github.com/owncloud/ocis/ocis-pkg/roles" @@ -14,13 +15,14 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - Logger log.Logger - Config *config.Config - Middleware []func(http.Handler) http.Handler - GatewayClient GatewayClient - HTTPClient HTTPClient - RoleService settingssvc.RoleService - RoleManager *roles.Manager + Logger log.Logger + Config *config.Config + Middleware []func(http.Handler) http.Handler + GatewayClient GatewayClient + HTTPClient HTTPClient + RoleService settingssvc.RoleService + RoleManager *roles.Manager + EventsPublisher events.Publisher } // newOptions initializes the available default options. @@ -82,3 +84,10 @@ func RoleManager(val *roles.Manager) Option { o.RoleManager = val } } + +// EventsPublisher provides a function to set the EventsPublisher option. +func EventsPublisher(val events.Publisher) Option { + return func(o *Options) { + o.EventsPublisher = val + } +} diff --git a/graph/pkg/service/v0/service.go b/graph/pkg/service/v0/service.go index 7ac1d3590..7833d4fd6 100644 --- a/graph/pkg/service/v0/service.go +++ b/graph/pkg/service/v0/service.go @@ -96,6 +96,7 @@ func NewService(opts ...Option) Service { logger: &options.Logger, identityBackend: backend, spacePropertiesCache: ttlcache.NewCache(), + eventsPublisher: options.EventsPublisher, } if options.GatewayClient == nil { var err error diff --git a/graph/pkg/service/v0/users.go b/graph/pkg/service/v0/users.go index 0cc29954f..d124da3fd 100644 --- a/graph/pkg/service/v0/users.go +++ b/graph/pkg/service/v0/users.go @@ -12,6 +12,7 @@ import ( "github.com/CiscoM31/godata" revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/events" "github.com/go-chi/chi/v5" "github.com/go-chi/render" libregraph "github.com/owncloud/libre-graph-api-go" @@ -133,6 +134,9 @@ func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf("could not assign role to account %s", err.Error())) return } + + g.publishEvent(events.UserCreated{UserID: *u.Id}) + render.Status(r, http.StatusOK) render.JSON(w, r, u) } @@ -187,6 +191,9 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) } } + + g.publishEvent(events.UserDeleted{UserID: userID}) + render.Status(r, http.StatusNoContent) render.NoContent(w, r) } @@ -211,12 +218,18 @@ func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) { return } + var features []events.UserFeature if mail, ok := changes.GetMailOk(); ok { if !isValidEmail(*mail) { errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("'%s' is not a valid email address", *mail)) return } + features = append(features, events.UserFeature{Name: "email", Value: *mail}) + } + + if name, ok := changes.GetDisplayNameOk(); ok { + features = append(features, events.UserFeature{Name: "displayname", Value: *name}) } u, err := g.identityBackend.UpdateUser(r.Context(), nameOrID, *changes) @@ -229,6 +242,12 @@ func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) { } } + g.publishEvent( + events.UserFeatureChanged{ + UserID: nameOrID, + Features: features, + }, + ) render.Status(r, http.StatusOK) render.JSON(w, r, u)