diff --git a/services/userlog/README.md b/services/userlog/README.md index 701fbfa293..ca686f19c9 100644 --- a/services/userlog/README.md +++ b/services/userlog/README.md @@ -34,10 +34,20 @@ The `userlog` service provides an API to retrieve configured events. For now, th Additionally to the oc10 API, the `userlog` service also provides an `/sse` (Server-Sent Events) endpoint to be informed by the server when an event happens. See [What is Server-Sent Events](https://medium.com/yemeksepeti-teknoloji/what-is-server-sent-events-sse-and-how-to-implement-it-904938bffd73) for a simple introduction and examples of server sent events. The `sse` endpoint will respect language changes of the user without needing to reconnect. Note that SSE has a limitation of six open connections per browser which can be reached if one has opened various tabs of the Web UI pointing to the same Infinite Scale instance. +## Posting + +The userlog service is able to store global messages that will be displayed in the Web UI to all users. These messages can only be activated and deleted by users with the `admin` role but not by ordinary users. If a user deletes the message in the Web UI, it reappears on reload. Global messages use the endpoint `/ocs/v2.php/apps/notifications/api/v1/notifications/global` and are activated by sending a `POST` request. Note that sending another `POST` request of the same type overwrites the previous one. For the time being, only the type `deprovision` is supported. + +### Deprovisioning + +Deprovision messages announce a deprovision text including a deprovision date of the instance to all users. With this message, users get informed that the instance will be shut down and deprovisioned and no further access to their data is possible past the given date. This implies that users must download their data before the given date. The text shown to users refers to this information. Note that the task to deprovision the instance does not depend on the message. The text of the message can be translated according to the translation settings, see section [Translations](#translations). The endpoint only expects a `deprovision_date` parameter in the `POST` request body as the final text is assembled automatically. The string hast to be in `RFC3339` format, however, this format can be changed by using `deprovision_date_format`. See the [go time formating](https://pkg.go.dev/time#pkg-constants) for more details. + ## Deleting To delete events for an user, use a `DELETE` request to `ocs/v2.php/apps/notifications/api/v1/notifications` containing the IDs to delete. +Only users with the `admin` role can send a `DELETE` request to the `ocs/v2.php/apps/notifications/api/v1/notifications/global` endpoint to remove a global message, see the [Posting](#posting) section for more details.) + ## Translations The `userlog` service has embedded translations sourced via transifex to provide a basic set of translated languages. These embedded translations are available for all deployment scenarios. In addition, the service supports custom translations, though it is currently not possible to just add custom translations to embedded ones. If custom translations are configured, the embedded ones are not used. To configure custom translations, the `USERLOG_TRANSLATION_PATH` environment variable needs to point to a base folder that will contain the translation files. This path must be available from all instances of the userlog service, a shared storage is recommended. Translation files must be of type [.po](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html#PO-Files) or [.mo](https://www.gnu.org/software/gettext/manual/html_node/Binaries.html). For each language, the filename needs to be `userlog.po` (or `userlog.mo`) and stored in a folder structure defining the language code. In general the path/name pattern for a translation file needs to be: diff --git a/services/userlog/pkg/command/server.go b/services/userlog/pkg/command/server.go index 7417c1709b..3698714291 100644 --- a/services/userlog/pkg/command/server.go +++ b/services/userlog/pkg/command/server.go @@ -104,6 +104,7 @@ func Server(cfg *config.Config) *cli.Command { hClient := ehsvc.NewEventHistoryService("com.owncloud.api.eventhistory", ogrpc.DefaultClient()) vClient := settingssvc.NewValueService("com.owncloud.api.settings", ogrpc.DefaultClient()) + rClient := settingssvc.NewRoleService("com.owncloud.api.settings", ogrpc.DefaultClient()) { server, err := http.Server( @@ -116,6 +117,7 @@ func Server(cfg *config.Config) *cli.Command { http.GatewaySelector(gatewaySelector), http.History(hClient), http.Value(vClient), + http.Role(rClient), http.RegisteredEvents(_registeredEvents), ) diff --git a/services/userlog/pkg/server/http/option.go b/services/userlog/pkg/server/http/option.go index efe691101c..6b55f5ba6b 100644 --- a/services/userlog/pkg/server/http/option.go +++ b/services/userlog/pkg/server/http/option.go @@ -31,6 +31,7 @@ type Options struct { GatewaySelector pool.Selectable[gateway.GatewayAPIClient] HistoryClient ehsvc.EventHistoryService ValueClient settingssvc.ValueService + RoleClient settingssvc.RoleService RegisteredEvents []events.Unmarshaller } @@ -128,3 +129,10 @@ func Value(vs settingssvc.ValueService) Option { o.ValueClient = vs } } + +// Roles provides a function to configure the roles service client +func Role(rs settingssvc.RoleService) Option { + return func(o *Options) { + o.RoleClient = rs + } +} diff --git a/services/userlog/pkg/server/http/server.go b/services/userlog/pkg/server/http/server.go index 705529ed24..4fd8527d83 100644 --- a/services/userlog/pkg/server/http/server.go +++ b/services/userlog/pkg/server/http/server.go @@ -77,6 +77,7 @@ func Server(opts ...Option) (http.Service, error) { svc.HistoryClient(options.HistoryClient), svc.GatewaySelector(options.GatewaySelector), svc.ValueClient(options.ValueClient), + svc.RoleClient(options.RoleClient), svc.RegisteredEvents(options.RegisteredEvents), ) if err != nil { diff --git a/services/userlog/pkg/service/conversion.go b/services/userlog/pkg/service/conversion.go index e10191f90d..df472094a8 100644 --- a/services/userlog/pkg/service/conversion.go +++ b/services/userlog/pkg/service/conversion.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "embed" + "encoding/json" "fmt" "io/fs" "strings" @@ -28,6 +29,7 @@ var ( _resourceTypeResource = "resource" _resourceTypeSpace = "storagespace" _resourceTypeShare = "share" + _resourceTypeGlobal = "global" _domain = "userlog" ) @@ -116,6 +118,22 @@ func (c *Converter) ConvertEvent(eventid string, event interface{}) (OC10Notific } } +// ConvertGlobalEvent converts a global event to an OC10Notification +func (c *Converter) ConvertGlobalEvent(typ string, data json.RawMessage) (OC10Notification, error) { + switch typ { + default: + return OC10Notification{}, fmt.Errorf("unknown global event type: %s", typ) + case "deprovision": + var dd DeprovisionData + if err := json.Unmarshal(data, &dd); err != nil { + return OC10Notification{}, err + } + + return c.deprovisionMessage(PlatformDeprovision, dd.DeprovisionDate.Format(dd.DeprovisionFormat)) + } + +} + func (c *Converter) spaceDeletedMessage(eventid string, executant *user.UserId, spaceid string, spacename string, ts time.Time) (OC10Notification, error) { usr, err := c.getUser(context.Background(), executant) if err != nil { @@ -287,6 +305,28 @@ func (c *Converter) policiesMessage(eventid string, nt NotificationTemplate, exe }, nil } +func (c *Converter) deprovisionMessage(nt NotificationTemplate, deproDate string) (OC10Notification, error) { + subj, subjraw, msg, msgraw, err := composeMessage(nt, c.locale, c.translationPath, map[string]interface{}{ + "date": deproDate, + }) + if err != nil { + return OC10Notification{}, err + } + + return OC10Notification{ + EventID: "deprovision", + Service: c.serviceName, + // UserName: executant.GetUsername(), // TODO: do we need the deprovisioner? + Timestamp: time.Now().Format(time.RFC3339Nano), // Fake timestamp? Or we store one with the event? + ResourceType: _resourceTypeResource, + Subject: subj, + SubjectRaw: subjraw, + Message: msg, + MessageRaw: msgraw, + MessageDetails: map[string]interface{}{}, + }, nil +} + func (c *Converter) authenticate(usr *user.User) (context.Context, error) { if ctx, ok := c.contexts[usr.GetId().GetOpaqueId()]; ok { return ctx, nil diff --git a/services/userlog/pkg/service/globalevents.go b/services/userlog/pkg/service/globalevents.go new file mode 100644 index 0000000000..b1ba5e568b --- /dev/null +++ b/services/userlog/pkg/service/globalevents.go @@ -0,0 +1,17 @@ +package service + +import "time" + +var ( + _globalEventsKey = "global-events" +) + +// DeprovisionData is the data needed for the deprovision global event +type DeprovisionData struct { + // The deprovision date + DeprovisionDate time.Time `json:"deprovision_date"` + // The Format of the deprvision date + DeprovisionFormat string + // The user who stored the deprovision message + Deprovisioner string +} diff --git a/services/userlog/pkg/service/http.go b/services/userlog/pkg/service/http.go index b1a9e82588..6d42bab474 100644 --- a/services/userlog/pkg/service/http.go +++ b/services/userlog/pkg/service/http.go @@ -6,6 +6,10 @@ import ( "github.com/cs3org/reva/v2/pkg/ctx" revactx "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/ocis-pkg/roles" + "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode" + settings "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0" ) // HeaderAcceptLanguage is the header where the client can set the locale @@ -57,6 +61,23 @@ func (ul *UserlogService) HandleGetEvents(w http.ResponseWriter, r *http.Request resp.OCS.Data = append(resp.OCS.Data, noti) } + glevs, err := ul.GetGlobalEvents() + if err != nil { + ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("get global events failed") + w.WriteHeader(http.StatusInternalServerError) + return + } + + for t, data := range glevs { + noti, err := conv.ConvertGlobalEvent(t, data) + if err != nil { + ul.log.Error().Err(err).Str("eventtype", t).Msg("failed to convert event") + continue + } + + resp.OCS.Data = append(resp.OCS.Data, noti) + } + resp.OCS.Meta.StatusCode = http.StatusOK b, _ := json.Marshal(resp) w.Write(b) @@ -89,6 +110,42 @@ func (ul *UserlogService) HandleSSE(w http.ResponseWriter, r *http.Request) { ul.sse.ServeHTTP(w, r) } +// HandlePostGlobaelEvent is the POST handler for global events +func (ul *UserlogService) HandlePostGlobalEvent(w http.ResponseWriter, r *http.Request) { + var req PostEventsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ul.log.Error().Err(err).Int("returned statuscode", http.StatusBadRequest).Msg("request body is malformed") + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := ul.StoreGlobalEvent(req.Type, req.Data); err != nil { + ul.log.Error().Err(err).Msg("post: error storing global event") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// HandleDeleteGlobalEvent is the DELETE handler for global events +func (ul *UserlogService) HandleDeleteGlobalEvent(w http.ResponseWriter, r *http.Request) { + var req DeleteEventsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ul.log.Error().Err(err).Int("returned statuscode", http.StatusBadRequest).Msg("request body is malformed") + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := ul.DeleteGlobalEvents(req.IDs); err != nil { + ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("delete events failed") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + // HandleDeleteEvents is the DELETE handler for events func (ul *UserlogService) HandleDeleteEvents(w http.ResponseWriter, r *http.Request) { u, ok := revactx.ContextGetUser(r.Context()) @@ -130,3 +187,50 @@ type GetEventResponseOC10 struct { type DeleteEventsRequest struct { IDs []string `json:"ids"` } + +// PostEventsRequest is the expected body for the post request +type PostEventsRequest struct { + // the event type, e.g. "deprovision" + Type string `json:"type"` + // arbitray data for the event + Data map[string]string `json:"data"` +} + +// RequireAdmin middleware is used to require the user in context to be an admin / have account management permissions +func RequireAdmin(rm *roles.Manager, logger log.Logger) func(next http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, ok := revactx.ContextGetUser(r.Context()) + if !ok || u.GetId().GetOpaqueId() == "" { + logger.Error().Str("userid", u.Id.OpaqueId).Msg("user not in context") + errorcode.AccessDenied.Render(w, r, http.StatusInternalServerError, "") + return + } + // get roles from context + roleIDs, ok := roles.ReadRoleIDsFromContext(r.Context()) + if !ok { + logger.Debug().Str("userid", u.Id.OpaqueId).Msg("No roles in context, contacting settings service") + var err error + roleIDs, err = rm.FindRoleIDsForUser(r.Context(), u.Id.OpaqueId) + if err != nil { + logger.Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to get roles for user") + errorcode.AccessDenied.Render(w, r, http.StatusInternalServerError, "") + return + } + if len(roleIDs) == 0 { + logger.Err(err).Str("userid", u.Id.OpaqueId).Msg("user has no roles") + errorcode.AccessDenied.Render(w, r, http.StatusInternalServerError, "") + return + } + } + + // check if permission is present in roles of the authenticated account + if rm.FindPermissionByID(r.Context(), roleIDs, settings.AccountManagementPermissionID) != nil { + next.ServeHTTP(w, r) + return + } + + errorcode.AccessDenied.Render(w, r, http.StatusNotFound, "Forbidden") + } + } +} diff --git a/services/userlog/pkg/service/options.go b/services/userlog/pkg/service/options.go index 98628cfd6f..12e454ec27 100644 --- a/services/userlog/pkg/service/options.go +++ b/services/userlog/pkg/service/options.go @@ -25,6 +25,7 @@ type Options struct { HistoryClient ehsvc.EventHistoryService GatewaySelector pool.Selectable[gateway.GatewayAPIClient] ValueClient settingssvc.ValueService + RoleClient settingssvc.RoleService RegisteredEvents []events.Unmarshaller } @@ -84,8 +85,16 @@ func RegisteredEvents(e []events.Unmarshaller) Option { } } +// ValueClient adds a grpc client for the value service func ValueClient(vs settingssvc.ValueService) Option { return func(o *Options) { o.ValueClient = vs } } + +// RoleClient adds a grpc client for the role service +func RoleClient(rs settingssvc.RoleService) Option { + return func(o *Options) { + o.RoleClient = rs + } +} diff --git a/services/userlog/pkg/service/service.go b/services/userlog/pkg/service/service.go index 1c105bccd0..cb97beef53 100644 --- a/services/userlog/pkg/service/service.go +++ b/services/userlog/pkg/service/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "reflect" + "time" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" @@ -19,6 +20,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" + "github.com/owncloud/ocis/v2/ocis-pkg/roles" ehmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0" ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" @@ -80,9 +82,17 @@ func NewUserlogService(opts ...Option) (*UserlogService, error) { ul.registeredEvents[typ.String()] = e } + m := roles.NewManager( + // TODO: caching? + roles.Logger(o.Logger), + roles.RoleService(o.RoleClient), + ) + ul.m.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) { r.Get("/", ul.HandleGetEvents) r.Delete("/", ul.HandleDeleteEvents) + r.Post("/global", RequireAdmin(&m, ul.log)(ul.HandlePostGlobalEvent)) + r.Delete("/global", RequireAdmin(&m, ul.log)(ul.HandleDeleteGlobalEvent)) if !ul.cfg.DisableSSE { r.Get("/sse", ul.HandleSSE) @@ -235,6 +245,74 @@ func (ul *UserlogService) DeleteEvents(userid string, evids []string) error { }) } +// StoreGlobalEvent will store a global event that will be returned with each `GetEvents` request +func (ul *UserlogService) StoreGlobalEvent(typ string, data map[string]string) error { + switch typ { + default: + return fmt.Errorf("unknown event type: %s", typ) + case "deprovision": + dps, ok := data["deprovision_date"] + if !ok { + return errors.New("need 'deprovision_date' in request body") + } + + format := data["deprovision_date_format"] + if format == "" { + format = time.RFC3339 + } + + date, err := time.Parse(format, dps) + if err != nil { + fmt.Println("", format, "\n", dps) + return fmt.Errorf("cannot parse time to format. time: '%s' format: '%s'", dps, format) + } + + ev := DeprovisionData{ + DeprovisionDate: date, + DeprovisionFormat: format, + } + + b, err := json.Marshal(ev) + if err != nil { + return err + } + + return ul.alterGlobalEvents(func(evs map[string]json.RawMessage) error { + evs[typ] = b + return nil + }) + } + +} + +// GetGlobalEvents will return all global events +func (ul *UserlogService) GetGlobalEvents() (map[string]json.RawMessage, error) { + out := make(map[string]json.RawMessage) + + recs, err := ul.store.Read(_globalEventsKey) + if err != nil && err != store.ErrNotFound { + return out, err + } + + if len(recs) > 0 { + if err := json.Unmarshal(recs[0].Value, &out); err != nil { + return out, err + } + } + + return out, nil +} + +// DeleteGlobalEvents will delete the specified event +func (ul *UserlogService) DeleteGlobalEvents(evnames []string) error { + return ul.alterGlobalEvents(func(evs map[string]json.RawMessage) error { + for _, name := range evnames { + delete(evs, name) + } + return nil + }) +} + func (ul *UserlogService) addEventToUser(userid string, event events.Event) error { if !ul.cfg.DisableSSE { if err := ul.sendSSE(userid, event); err != nil { @@ -312,6 +390,27 @@ func (ul *UserlogService) alterUserEventList(userid string, alter func([]string) }) } +func (ul *UserlogService) alterGlobalEvents(alter func(map[string]json.RawMessage) error) error { + evs, err := ul.GetGlobalEvents() + if err != nil && err != store.ErrNotFound { + return err + } + + if err := alter(evs); err != nil { + return err + } + + val, err := json.Marshal(evs) + if err != nil { + return err + } + + return ul.store.Write(&store.Record{ + Key: "global-events", + Value: val, + }) +} + // we need the spaceid to inform other space members // we need an owner to query space members // we need to check the user has the required role to see the event diff --git a/services/userlog/pkg/service/templates.go b/services/userlog/pkg/service/templates.go index a6616d02e5..6acf6fb8ca 100644 --- a/services/userlog/pkg/service/templates.go +++ b/services/userlog/pkg/service/templates.go @@ -54,6 +54,11 @@ var ( Subject: Template("Share expired"), Message: Template("Access to {resource} expired"), } + + PlatformDeprovision = NotificationTemplate{ + Subject: Template("Instance will be shut down and deprovisioned"), + Message: Template("Attention! The instance will be shut down and deprovisioned on {date}. Download all your data before that date as no access past that date is possible."), + } ) // holds the information to turn the raw template into a parseable go template @@ -62,6 +67,7 @@ var _placeholders = map[string]string{ "{space}": "{{ .spacename }}", "{resource}": "{{ .resourcename }}", "{virus}": "{{ .virusdescription }}", + "{date}": "{{ .date }}", } // NotificationTemplate is the data structure for the notifications