Compare commits

...

10 Commits

Author SHA1 Message Date
Viktor Scharf
8288abf63e deleting user by userId in test 2025-09-26 16:27:37 +02:00
Christian Richter
b1bc63274f attempt to fix broken soft delete
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-25 17:08:15 +02:00
Christian Richter
d76f528c00 use standard errors package
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
c15bb0f99b remove obsolete properties
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
11900601d2 revert faulty replaces
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
16d600cffe add missing pointer
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
3272b4862a respect ldap settings, add comments
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
29804e355b add persistance function & userstate
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
Christian Richter
9c4f74d394 add nats-js-kv connection to graph
Signed-off-by: Christian Richter <c.richter@opencloud.eu>

# Conflicts:
#	services/graph/pkg/service/v0/service.go
2025-09-24 15:03:31 +02:00
Christian Richter
55a6f057e5 add nats-js-kv persistance to graph
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-09-24 15:03:31 +02:00
7 changed files with 207 additions and 8 deletions

View File

@@ -42,6 +42,8 @@ type Config struct {
Metadata Metadata `yaml:"metadata_config"`
UserSoftDeleteRetentionTime time.Duration `yaml:"user_soft_delete_retention_time" env:"GRAPH_USER_SOFT_DELETE_RETENTION_TIME" desc:"The time after which a soft-deleted user is permanently deleted. If set to 0 (default), there is no soft delete retention time and users are deleted immediately after being soft-deleted. If set to a positive value, the user will be kept in the system for that duration before being permanently deleted." introductionVersion:"%%NEXT%%"`
Store Store `yaml:"store"`
}
type Spaces struct {
@@ -168,3 +170,11 @@ type Metadata struct {
SystemUserIDP string `yaml:"system_user_idp" env:"OC_SYSTEM_USER_IDP;GRAPH_SYSTEM_USER_IDP" desc:"IDP of the OpenCloud STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"`
SystemUserAPIKey string `yaml:"system_user_api_key" env:"OC_SYSTEM_USER_API_KEY" desc:"API key for the STORAGE-SYSTEM system user." introductionVersion:"%%NEXT%%"`
}
// Store configures the store to use
type Store struct {
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;GRAPH_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"GRAPH_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;GRAPH_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}

View File

@@ -131,6 +131,10 @@ func DefaultConfig() *config.Config {
SystemUserIDP: "internal",
},
UserSoftDeleteRetentionTime: 0,
Store: config.Store{
Nodes: []string{"127.0.0.1:9233"},
Database: "graph",
},
}
}

View File

@@ -12,6 +12,7 @@ import (
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/jellydator/ttlcache/v3"
"github.com/nats-io/nats.go"
"go-micro.dev/v4/client"
"go.opentelemetry.io/otel/trace"
"google.golang.org/protobuf/types/known/emptypb"
@@ -67,6 +68,7 @@ type Graph struct {
keycloakClient keycloak.Client
historyClient ehsvc.EventHistoryService
traceProvider trace.TracerProvider
natskv nats.KeyValue
}
// ServeHTTP implements the Service interface.

View File

@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
ldapv3 "github.com/go-ldap/ldap/v3"
"github.com/jellydator/ttlcache/v3"
"github.com/nats-io/nats.go"
"github.com/riandyrn/otelchi"
microstore "go-micro.dev/v4/store"
@@ -153,6 +154,38 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
identity.IdentityCacheWithGroupsTTL(time.Duration(options.Config.Spaces.GroupsCacheTTL)),
)
// Connect to NATS servers
natsOptions := nats.Options{
Servers: options.Config.Store.Nodes,
}
conn, err := natsOptions.Connect()
if err != nil {
return Graph{}, err
}
js, err := conn.JetStream()
if err != nil {
return Graph{}, err
}
kv, err := js.KeyValue(options.Config.Store.Database)
if err != nil {
if !errors.Is(err, nats.ErrBucketNotFound) {
return Graph{}, fmt.Errorf("Failed to get bucket (%s): %w", options.Config.Store.Database, err)
}
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: options.Config.Store.Database,
})
if err != nil {
return Graph{}, fmt.Errorf("Failed to create bucket (%s): %w", options.Config.Store.Database, err)
}
}
if err != nil {
return Graph{}, err
}
baseGraphService := BaseGraphService{
logger: &options.Logger,
identityCache: identityCache,
@@ -198,6 +231,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
historyClient: options.EventHistoryClient,
traceProvider: options.TraceProvider,
valueService: options.ValueService,
natskv: kv,
}
if err := setIdentityBackends(options, &svc); err != nil {

View File

@@ -2,6 +2,7 @@ package svc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -22,12 +23,14 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/nats-io/nats.go"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
settingsmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/settings/v0"
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/graph/pkg/identity"
"github.com/opencloud-eu/opencloud/services/graph/pkg/odata"
"github.com/opencloud-eu/opencloud/services/graph/pkg/userstate"
ocsettingssvc "github.com/opencloud-eu/opencloud/services/settings/pkg/service/v0"
"github.com/opencloud-eu/opencloud/services/settings/pkg/store/defaults"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
@@ -642,7 +645,30 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if g.config.UserSoftDeleteRetentionTime > 0 && purgeUser && user.GetAccountEnabled() {
us, err := g.getUserStateFromNatsKeyValue(r.Context(), userID)
if err != nil {
logger.Error().Err(err).Str("id", userID).Msg("could not get user state")
us = userstate.UserState{
UserId: userID,
State: userstate.UserStateUnspecified,
}
}
if us.State == userstate.UserStateHardDeleted {
logger.Debug().Str("id", userID).Msg("could not delete user: user already hard deleted")
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "user not found")
return
}
if us.State == userstate.UserStateUnspecified {
if user.GetAccountEnabled() {
us.State = userstate.UserStateEnabled
} else {
us.State = userstate.UserStateSoftDeleted
}
}
if g.config.UserSoftDeleteRetentionTime > 0 && purgeUser && us.State == userstate.UserStateEnabled {
logger.Debug().Msg("could not delete user: purgeUser is set but user is still enabled")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user should be hard deleted, but is still enabled, please soft delete first")
return
@@ -684,7 +710,9 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
for _, sp := range lspr.GetStorageSpaces() {
if !(sp.SpaceType == _spaceTypePersonal && sp.Owner.Id.OpaqueId == user.GetId()) {
// if the spacetype equals _spaceTypePersonal and the owner id equals the user id
// then we found the personal space of the user to be deleted
if !(sp.GetSpaceType() == _spaceTypePersonal && sp.Owner.GetId().GetOpaqueId() == user.GetId()) {
continue
}
// TODO: check if request contains a homespace and if, check if requesting user has the privilege to
@@ -706,7 +734,7 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
}
}
// the space will if the system does not have a UserSoftDeleteRetentionTime configured, e.g. SoftDelete disabled
if g.config.UserSoftDeleteRetentionTime == 0 || (purgeUser && !user.GetAccountEnabled()) {
if g.config.UserSoftDeleteRetentionTime == 0 || (purgeUser && us.State == userstate.UserStateSoftDeleted) {
purgeSpaceFlag := utils.AppendPlainToOpaque(nil, "purge", "")
_, err := client.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
Opaque: purgeSpaceFlag,
@@ -725,24 +753,41 @@ func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
}
}
if g.config.UserSoftDeleteRetentionTime == 0 || (purgeUser && !user.GetAccountEnabled()) {
if (g.config.UserSoftDeleteRetentionTime > 0 && us.State == userstate.UserStateSoftDeleted && purgeUser) ||
(g.config.UserSoftDeleteRetentionTime == 0) {
logger.Debug().Str("id", user.GetId()).Msg("calling delete user on backend")
err = g.identityBackend.DeleteUser(r.Context(), user.GetId())
if err != nil {
logger.Debug().Err(err).Msg("could not delete user: backend error")
errorcode.RenderError(w, r, err)
return
}
us.State = userstate.UserStateHardDeleted
err = g.setUserStateToNatsKeyValue(r.Context(), userID, us)
if err != nil {
logger.Error().Err(err).Str("id", userID).Msg("could not set user state")
errorcode.RenderError(w, r, err)
}
} else {
logger.Debug().Str("id", user.GetId()).Msg("calling soft delete user on backend")
userUpdate := *libregraph.NewUserUpdate()
userUpdate.AccountEnabled = libregraph.PtrBool(false)
us.State = userstate.UserStateSoftDeleted
us.RetentionPeriod = g.config.UserSoftDeleteRetentionTime
us.Reason = "User soft deleted via Graph API" // TODO: this needs a proper implementation through the request
us.TimeStamp = time.Now()
err = g.setUserStateToNatsKeyValue(r.Context(), userID, us)
if err != nil {
logger.Error().Err(err).Str("id", userID).Msg("could not set user state")
errorcode.RenderError(w, r, err)
return
}
g.identityBackend.UpdateUser(r.Context(), user.GetId(), userUpdate)
}
if g.config.UserSoftDeleteRetentionTime == 0 ||
(g.config.UserSoftDeleteRetentionTime > 0 && purgeUser && !user.GetAccountEnabled()) {
(g.config.UserSoftDeleteRetentionTime > 0 && purgeUser && us.State == userstate.UserStateSoftDeleted) {
e := events.UserDeleted{UserID: user.GetId()}
e.Executant = currentUser.GetId()
g.publishEvent(r.Context(), e)
@@ -1103,3 +1148,69 @@ func (g Graph) searchOCMAcceptedUsers(ctx context.Context, odataReq *godata.GoDa
}
return users, nil
}
// getUserStateFromNatsKeyValue gets the user state from the nats key value store.
func (g Graph) getUserStateFromNatsKeyValue(ctx context.Context, userID string) (userstate.UserState, error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if g.natskv == nil {
logger.Debug().Msg("nats connection or user state key value store not configured")
return userstate.UserState{
UserId: userID,
State: userstate.UserStateUnspecified,
}, nil
}
entry, err := g.natskv.Get(userID)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
logger.Debug().Str("userid", userID).Msg("no user state found in nats key value store")
return userstate.UserState{
UserId: userID,
State: userstate.UserStateUnspecified,
}, nil
}
logger.Error().Err(err).Str("userid", userID).Msg("error getting user state from nats key value store")
return userstate.UserState{
State: userstate.UserStateUnspecified,
}, err
}
userState := userstate.UserState{}
if err := json.Unmarshal(entry.Value(), &userState); err != nil {
logger.Error().Err(err).Str("userid", userID).Msg("error unmarshalling user state from nats key value store")
return userstate.UserState{
UserId: userID,
State: userstate.UserStateUnspecified,
}, err
}
return userState, nil
}
// setUserStateToNatsKeyValue sets the user state in the nats key value store.
func (g Graph) setUserStateToNatsKeyValue(ctx context.Context, userID string, us userstate.UserState) error {
logger := g.logger.SubloggerWithRequestID(ctx)
if ok, err := userstate.IsValidUserState(&us); !ok {
logger.Debug().Str("userid", userID).Msg("invalid user state")
return fmt.Errorf("invalid user state: %w", err)
}
if g.natskv == nil {
logger.Debug().Msg("nats connection or user state key value store not configured")
return nil
}
data, err := json.Marshal(us)
if err != nil {
logger.Error().Err(err).Str("userid", userID).Msg("error marshalling user state to nats key value store")
return err
}
if _, err := g.natskv.Put(userID, data); err != nil {
logger.Error().Err(err).Str("userid", userID).Msg("error putting user state to nats key value store")
return err
}
return nil
}

View File

@@ -0,0 +1,37 @@
package userstate
import (
"fmt"
"time"
)
const (
_ = iota
UserStateUnspecified
UserStateEnabled
UserStateDisabled
UserStateSoftDeleted
UserStateHardDeleted
)
// UserState represents the state of a user account.
// Note: This does not reflect state changes, these need to be red from the audit logs.
type UserState struct {
UserId string `json:"userid"`
State uint8 `json:"state"`
TimeStamp time.Time `json:"timestamp,omitempty"`
RetentionPeriod time.Duration `json:"retentionPeriod,omitempty"`
Reason string `json:"reason,omitempty,omitempty"`
}
func IsValidUserState(us *UserState) (bool, error) {
if us.State == UserStateSoftDeleted {
if us.RetentionPeriod <= 0 {
return false, fmt.Errorf("retention period must be greater than 0 for soft deleted users")
}
if us.Reason == "" {
return false, fmt.Errorf("reason must be provided for soft deleted users")
}
}
return true, nil
}

View File

@@ -289,12 +289,13 @@ class GraphContext implements Context {
*/
public function adminDeletesUserUsingTheGraphApi(string $user, ?string $byUser = null): ResponseInterface {
$credentials = $this->getAdminOrUserCredentials($byUser);
return GraphHelper::deleteUser(
$userId = $this->featureContext->getAttributeOfCreatedUser($user, 'id');
return GraphHelper::deleteUserByUserId(
$this->featureContext->getBaseUrl(),
$this->featureContext->getStepLineRef(),
$credentials["username"],
$credentials["password"],
$user
$userId
);
}