From 3c386dfd1d376f0e53a2b769dd02429e00fe9f38 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Fri, 17 Oct 2025 16:02:10 +0200 Subject: [PATCH] groupware: add identity deletion --- pkg/jmap/jmap_api_identity.go | 29 ++++++++++ pkg/structs/structs.go | 56 +++++++++++++++++++ pkg/structs/structs_test.go | 22 ++++++++ .../pkg/groupware/groupware_api_identity.go | 39 ++++++++++++- .../pkg/groupware/groupware_error.go | 11 +++- .../pkg/groupware/groupware_route.go | 1 + 6 files changed, 153 insertions(+), 5 deletions(-) diff --git a/pkg/jmap/jmap_api_identity.go b/pkg/jmap/jmap_api_identity.go index bb2465a1c..07d56353c 100644 --- a/pkg/jmap/jmap_api_identity.go +++ b/pkg/jmap/jmap_api_identity.go @@ -199,3 +199,32 @@ func (j *Client) UpdateIdentity(accountId string, session *Session, ctx context. return response.NewState, nil }) } + +type IdentityDeletion struct { + Destroyed []string `json:"destroyed"` + NewState State `json:"newState,omitempty"` +} + +func (j *Client) DeleteIdentity(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (IdentityDeletion, SessionState, Language, Error) { + logger = j.logger("DeleteIdentity", session, logger) + cmd, err := j.request(session, logger, invocation(CommandIdentitySet, IdentitySetCommand{ + AccountId: accountId, + Destroy: ids, + }, "0")) + if err != nil { + return IdentityDeletion{}, "", "", err + } + return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (IdentityDeletion, Error) { + var response IdentitySetResponse + err = retrieveResponseMatchParameters(logger, body, CommandIdentitySet, "0", &response) + if err != nil { + return IdentityDeletion{}, err + } + for _, setErr := range response.NotDestroyed { + // TODO only returning the first error here, we should probably aggregate them instead + logger.Error().Msgf("%T.NotCreated returned an error %v", response, setErr) + return IdentityDeletion{}, setErrorError(setErr, IdentityType) + } + return IdentityDeletion{Destroyed: response.Destroyed, NewState: response.NewState}, nil + }) +} diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index d9c30e608..6d95c5e07 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -67,6 +67,26 @@ func Map[E any, R any](source []E, indexer func(E) R) []R { return result } +func ToBoolMap[E comparable](source []E) map[E]bool { + m := make(map[E]bool, len(source)) + for _, v := range source { + m[v] = true + } + return m +} + +func ToIntMap[E comparable](source []E) map[E]int { + m := make(map[E]int, len(source)) + for _, v := range source { + if e, ok := m[v]; ok { + m[v] = e + 1 + } else { + m[v] = 1 + } + } + return m +} + func MapN[E any, R any](source []E, indexer func(E) *R) []R { if source == nil { var zero []R @@ -81,3 +101,39 @@ func MapN[E any, R any](source []E, indexer func(E) *R) []R { } return result } + +// Check whether two slices contain the same elements, ignoring order. +func SameSlices[E comparable](x, y []E) bool { + // https://stackoverflow.com/a/36000696 + if len(x) != len(y) { + return false + } + // create a map of string -> int + diff := make(map[E]int, len(x)) + for _, _x := range x { + // 0 value for int is 0, so just increment a counter for the string + diff[_x]++ + } + for _, _y := range y { + // If the string _y is not in diff bail out early + if _, ok := diff[_y]; !ok { + return false + } + diff[_y]-- + if diff[_y] == 0 { + delete(diff, _y) + } + } + return len(diff) == 0 +} + +func Missing[E comparable](expected, actual []E) []E { + missing := []E{} + actualIndex := ToBoolMap(actual) + for _, e := range expected { + if _, ok := actualIndex[e]; !ok { + missing = append(missing, e) + } + } + return missing +} diff --git a/pkg/structs/structs_test.go b/pkg/structs/structs_test.go index b06a5e31a..9defb8b45 100644 --- a/pkg/structs/structs_test.go +++ b/pkg/structs/structs_test.go @@ -2,6 +2,7 @@ package structs import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -99,3 +100,24 @@ func TestKeys(t *testing.T) { }) } } + +func TestMissing(t *testing.T) { + tests := []struct { + source []string + input []string + expected []string + }{ + {[]string{"a", "b", "c"}, []string{"c", "b", "a"}, []string{}}, + {[]string{"a", "b", "c"}, []string{"c", "b"}, []string{"a"}}, + {[]string{"a", "b", "c"}, []string{"c", "b", "a", "d"}, []string{}}, + {[]string{}, []string{"c", "b"}, []string{}}, + {[]string{"a", "b", "c"}, []string{}, []string{"a", "b", "c"}}, + {[]string{"a", "b", "b", "c"}, []string{"a", "b"}, []string{"c"}}, + } + for i, tt := range tests { + t.Run(fmt.Sprintf("%d: testing [%v] <-> [%v] == [%v]", i+1, strings.Join(tt.source, ", "), strings.Join(tt.input, ", "), strings.Join(tt.expected, ", ")), func(t *testing.T) { + result := Missing(tt.source, tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/services/groupware/pkg/groupware/groupware_api_identity.go b/services/groupware/pkg/groupware/groupware_api_identity.go index a6a2bc98c..e2ebf5516 100644 --- a/services/groupware/pkg/groupware/groupware_api_identity.go +++ b/services/groupware/pkg/groupware/groupware_api_identity.go @@ -1,11 +1,14 @@ package groupware import ( + "fmt" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/opencloud-eu/opencloud/pkg/jmap" "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/pkg/structs" ) // When the request suceeds. @@ -43,7 +46,7 @@ func (g *Groupware) GetIdentities(w http.ResponseWriter, r *http.Request) { func (g *Groupware) GetIdentityById(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - accountId, err := req.GetAccountIdWithoutFallback() + accountId, err := req.GetAccountIdForMail() if err != nil { return errorResponse(err) } @@ -62,7 +65,7 @@ func (g *Groupware) GetIdentityById(w http.ResponseWriter, r *http.Request) { func (g *Groupware) AddIdentity(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - accountId, err := req.GetAccountIdWithoutFallback() + accountId, err := req.GetAccountIdForMail() if err != nil { return errorResponse(err) } @@ -84,7 +87,7 @@ func (g *Groupware) AddIdentity(w http.ResponseWriter, r *http.Request) { func (g *Groupware) ModifyIdentity(w http.ResponseWriter, r *http.Request) { g.respond(w, r, func(req Request) Response { - accountId, err := req.GetAccountIdWithoutFallback() + accountId, err := req.GetAccountIdForMail() if err != nil { return errorResponse(err) } @@ -103,3 +106,33 @@ func (g *Groupware) ModifyIdentity(w http.ResponseWriter, r *http.Request) { return noContentResponseWithEtag(sessionState, newState) }) } + +func (g *Groupware) DeleteIdentity(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + accountId, err := req.GetAccountIdForMail() + if err != nil { + return errorResponse(err) + } + logger := log.From(req.logger.With().Str(logAccountId, accountId)) + + id := chi.URLParam(r, UriParamIdentityId) + ids := strings.Split(id, ",") + if len(ids) < 1 { + return req.parameterErrorResponse(UriParamEmailId, fmt.Sprintf("Invalid value for path parameter '%v': '%s': %s", UriParamIdentityId, log.SafeString(id), "empty list of identity ids")) + } + + deletion, sessionState, _, jerr := g.jmap.DeleteIdentity(accountId, req.session, req.ctx, logger, req.language(), ids) + if jerr != nil { + return req.errorResponseFromJmap(jerr) + } + + notDeletedIds := structs.Missing(ids, deletion.Destroyed) + if len(notDeletedIds) == 0 { + return noContentResponseWithEtag(sessionState, deletion.NewState) + } else { + logger.Error().Array("not-deleted", log.SafeStringArray(notDeletedIds)).Msgf("failed to delete %d identities", len(notDeletedIds)) + return errorResponseWithSessionState(req.apiError(&ErrorFailedToDeleteSomeIdentities, + withMeta(map[string]any{"ids": notDeletedIds})), sessionState) + } + }) +} diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index fc2b69c0f..9c9c22807 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -195,6 +195,7 @@ const ( ErrorCodeMissingTasksSessionCapability = "MSCTSK" ErrorCodeMissingTaskAccountCapability = "MACTSK" ErrorCodeFailedToDeleteEmail = "DELEML" + ErrorCodeFailedToDeleteSomeIdentities = "DELSID" ) var ( @@ -420,6 +421,12 @@ var ( Title: "Failed to delete emails", Detail: "One or more emails could not be deleted.", } + ErrorFailedToDeleteSomeIdentities = GroupwareError{ + Status: http.StatusInternalServerError, + Code: ErrorCodeFailedToDeleteSomeIdentities, + Title: "Failed to delete some Identities", + Detail: "Failed to delete some or all of the identities.", + } ) type ErrorOpt interface { @@ -576,12 +583,12 @@ func (r Request) observedParameterError(gwerr GroupwareError, options ...ErrorOp return r.observeParameterError(apiError(r.errorId(), gwerr, options...)) } -func (r Request) apiError(err *GroupwareError) *Error { +func (r Request) apiError(err *GroupwareError, options ...ErrorOpt) *Error { if err == nil { return nil } errorId := r.errorId() - return apiError(errorId, *err) + return apiError(errorId, *err, options...) } func (r Request) apiErrorFromJmap(err jmap.Error) *Error { diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 40515201e..4c86ef394 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -79,6 +79,7 @@ func (g *Groupware) Route(r chi.Router) { r.Get("/{identityid}", g.GetIdentityById) r.Post("/", g.AddIdentity) r.Patch("/{identityid}", g.ModifyIdentity) + r.Delete("/{identityid}", g.DeleteIdentity) }) r.Get("/vacation", g.GetVacation) r.Put("/vacation", g.SetVacation)