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)