diff --git a/changelog/unreleased/autoprovsion-groups.md b/changelog/unreleased/autoprovsion-groups.md new file mode 100644 index 0000000000..ede604f9c0 --- /dev/null +++ b/changelog/unreleased/autoprovsion-groups.md @@ -0,0 +1,7 @@ +Enhancement: Autoprovision group memberships + +When PROXY_AUTOPROVISION_ACCOUNTS is enabled it is now possible to automatically +maintain the group memberships of users via a configurable OIDC claim. + +https://github.com/owncloud/ocis/pull/9458 +https://github.com/owncloud/ocis/issues/5538 diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index ad7dac8e94..9c067c45d7 100644 --- a/services/proxy/pkg/config/config.go +++ b/services/proxy/pkg/config/config.go @@ -160,6 +160,7 @@ type AutoProvisionClaims struct { Username string `yaml:"username" env:"PROXY_AUTOPROVISION_CLAIM_USERNAME" desc:"The name of the OIDC claim that holds the username." introductionVersion:"6.0.0"` Email string `yaml:"email" env:"PROXY_AUTOPROVISION_CLAIM_EMAIL" desc:"The name of the OIDC claim that holds the email." introductionVersion:"6.0.0"` DisplayName string `yaml:"display_name" env:"PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME" desc:"The name of the OIDC claim that holds the display name." introductionVersion:"6.0.0"` + Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"6.1.0"` } // PolicySelector is the toplevel-configuration for different selectors diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 85ce23344f..d37c83ac9e 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -88,6 +88,7 @@ func DefaultConfig() *config.Config { Username: "preferred_username", Email: "email", DisplayName: "name", + Groups: "groups", }, EnableBasicAuth: false, InsecureBackends: false, diff --git a/services/proxy/pkg/middleware/account_resolver.go b/services/proxy/pkg/middleware/account_resolver.go index 868e132886..f560af347e 100644 --- a/services/proxy/pkg/middleware/account_resolver.go +++ b/services/proxy/pkg/middleware/account_resolver.go @@ -4,7 +4,9 @@ import ( "errors" "fmt" "net/http" + "time" + "github.com/jellydator/ttlcache/v3" "github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend" "github.com/owncloud/ocis/v2/services/proxy/pkg/userroles" @@ -19,6 +21,12 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl options := newOptions(optionSetters...) logger := options.Logger + lastGroupSyncCache := ttlcache.New( + ttlcache.WithTTL[string, struct{}](5*time.Minute), + ttlcache.WithDisableTouchOnHit[string, struct{}](), + ) + go lastGroupSyncCache.Start() + return func(next http.Handler) http.Handler { return &accountResolver{ next: next, @@ -28,6 +36,7 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl userCS3Claim: options.UserCS3Claim, userRoleAssigner: options.UserRoleAssigner, autoProvisionAccounts: options.AutoprovisionAccounts, + lastGroupSyncCache: lastGroupSyncCache, } } } @@ -40,6 +49,10 @@ type accountResolver struct { autoProvisionAccounts bool userOIDCClaim string userCS3Claim string + // lastGroupSyncCache is used to keep track of when the last sync of group + // memberships was done for a specific user. This is used to trigger a sync + // with every single request. + lastGroupSyncCache *ttlcache.Cache[string, struct{}] } func readUserIDClaim(path string, claims map[string]interface{}) (string, error) { @@ -140,6 +153,15 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } + // Only sync group memberships if the user has not been synced since the last cache invalidation + if !m.lastGroupSyncCache.Has(user.GetId().GetOpaqueId()) { + if err = m.userProvider.SyncGroupMemberships(req.Context(), user, claims); err != nil { + m.logger.Error().Err(err).Str("userid", user.GetId().GetOpaqueId()).Interface("claims", claims).Msg("Failed to sync group memberships for autoprovisioned user") + w.WriteHeader(http.StatusInternalServerError) + return + } + m.lastGroupSyncCache.Set(user.GetId().GetOpaqueId(), struct{}{}, ttlcache.DefaultTTL) + } } // resolve the user's roles diff --git a/services/proxy/pkg/user/backend/backend.go b/services/proxy/pkg/user/backend/backend.go index 229ef7676b..76f83ce049 100644 --- a/services/proxy/pkg/user/backend/backend.go +++ b/services/proxy/pkg/user/backend/backend.go @@ -22,4 +22,5 @@ type UserBackend interface { Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error) CreateUserFromClaims(ctx context.Context, claims map[string]interface{}) (*cs3.User, error) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]interface{}) error + SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]interface{}) error } diff --git a/services/proxy/pkg/user/backend/cs3.go b/services/proxy/pkg/user/backend/cs3.go index 28e4525618..1d24cf30fc 100644 --- a/services/proxy/pkg/user/backend/cs3.go +++ b/services/proxy/pkg/user/backend/cs3.go @@ -3,6 +3,7 @@ package backend import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -12,6 +13,7 @@ import ( rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" revactx "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + utils "github.com/cs3org/reva/v2/pkg/utils" libregraph "github.com/owncloud/libre-graph-api-go" "go-micro.dev/v4/selector" @@ -40,6 +42,10 @@ type Options struct { autoProvisionClaims config.AutoProvisionClaims } +var ( + errGroupNotFound = errors.New("group not found") +) + // WithLogger sets the logger option func WithLogger(l log.Logger) Option { return func(o *Options) { @@ -243,6 +249,143 @@ func (c cs3backend) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, clai return nil } +// SyncGroupMemberships maintains a users group memberships based on an OIDC claim +func (c cs3backend) SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]interface{}) error { + gatewayClient, err := c.gatewaySelector.Next() + if err != nil { + c.logger.Error().Err(err).Msg("could not select next gateway client") + return err + } + newctx := context.Background() + token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret) + if err != nil { + c.logger.Error().Err(err).Msg("Error getting token for service user") + return err + } + + lgClient, err := c.setupLibregraphClient(newctx, token) + if err != nil { + c.logger.Error().Err(err).Msg("Error setting up libregraph client") + return err + } + + lgUser, resp, err := lgClient.UserApi.GetUser(newctx, user.GetId().GetOpaqueId()).Expand([]string{"memberOf"}).Execute() + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + c.logger.Error().Err(err).Msg("Failed to lookup user via libregraph") + return err + } + + currentGroups := lgUser.GetMemberOf() + currentGroupSet := make(map[string]struct{}) + for _, group := range currentGroups { + currentGroupSet[group.GetDisplayName()] = struct{}{} + } + + newGroupSet := make(map[string]struct{}) + if groups, ok := claims[c.autoProvisionClaims.Groups].([]interface{}); ok { + for _, g := range groups { + if group, ok := g.(string); ok { + newGroupSet[group] = struct{}{} + } + } + } + + for group := range newGroupSet { + if _, exists := currentGroupSet[group]; !exists { + c.logger.Debug().Str("group", group).Msg("adding user to group") + // Check if group exists + lgGroup, err := c.getLibregraphGroup(newctx, lgClient, group) + switch { + case errors.Is(err, errGroupNotFound): + newGroup := libregraph.Group{} + newGroup.SetDisplayName(group) + req := lgClient.GroupsApi.CreateGroup(newctx).Group(newGroup) + var resp *http.Response + lgGroup, resp, err = req.Execute() + if resp != nil { + defer resp.Body.Close() + } + switch { + case err == nil: + // all good + case resp == nil: + return err + default: + // Ignore error if group already exists + exists, lerr := c.isAlreadyExists(resp) + switch { + case lerr != nil: + c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.") + return err + case !exists: + c.logger.Error().Err(err).Msg("Failed to create group via libregraph") + return err + default: + // group has been created meanwhile, re-read it to get the group id + lgGroup, err = c.getLibregraphGroup(newctx, lgClient, group) + if err != nil { + return err + } + } + } + case err != nil: + return err + } + + memberref := "https://localhost/graph/v1.0/users/" + user.GetId().GetOpaqueId() + resp, err := lgClient.GroupApi.AddMember(newctx, lgGroup.GetId()).MemberReference( + libregraph.MemberReference{ + OdataId: &memberref, + }, + ).Execute() + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + c.logger.Error().Err(err).Msg("Failed to add user to group via libregraph") + } + } + } + for current := range currentGroupSet { + if _, exists := newGroupSet[current]; !exists { + c.logger.Debug().Str("group", current).Msg("deleting user from group") + lgGroup, err := c.getLibregraphGroup(newctx, lgClient, current) + if err != nil { + return err + } + resp, err := lgClient.GroupApi.DeleteMember(newctx, lgGroup.GetId(), user.GetId().GetOpaqueId()).Execute() + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + return err + } + } + } + return nil +} + +func (c cs3backend) getLibregraphGroup(ctx context.Context, client *libregraph.APIClient, group string) (*libregraph.Group, error) { + lgGroup, resp, err := client.GroupApi.GetGroup(ctx, group).Execute() + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + switch { + case resp == nil: + return nil, err + case resp.StatusCode == http.StatusNotFound: + return nil, errGroupNotFound + case resp.StatusCode != http.StatusOK: + return nil, err + } + } + return lgGroup, nil +} + func (c cs3backend) updateLibregraphUser(userid string, user libregraph.User) error { gatewayClient, err := c.gatewaySelector.Next() if err != nil { @@ -250,19 +393,13 @@ func (c cs3backend) updateLibregraphUser(userid string, user libregraph.User) er return err } newctx := context.Background() - authRes, err := gatewayClient.Authenticate(newctx, &gateway.AuthenticateRequest{ - Type: "serviceaccounts", - ClientId: c.serviceAccount.ServiceAccountID, - ClientSecret: c.serviceAccount.ServiceAccountSecret, - }) + token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret) if err != nil { + c.logger.Error().Err(err).Msg("Error getting token for service user") return err } - if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { - return fmt.Errorf("error authenticating service user: %s", authRes.GetStatus().GetMessage()) - } - lgClient, err := c.setupLibregraphClient(newctx, authRes.GetToken()) + lgClient, err := c.setupLibregraphClient(newctx, token) if err != nil { c.logger.Error().Err(err).Msg("Error setting up libregraph client") return err