Merge pull request #6672 from kobergj/DeprovisionNotification

Deprovisioning Notification
This commit is contained in:
kobergj
2023-07-10 11:07:09 +02:00
committed by GitHub
10 changed files with 296 additions and 0 deletions

View File

@@ -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:

View File

@@ -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),
)

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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