autoprovisioning: sync group memberships

Add support for autoprovisioning group memberships from OIDC claims.
Users are added to and removed from groups based on the value of an OIDC
claim. If a group does not exist, it is created.

Closes: #5538
This commit is contained in:
Ralf Haferkamp
2024-06-24 18:10:05 +02:00
parent c4101fa9e2
commit 0d65908e82
6 changed files with 178 additions and 9 deletions
@@ -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
+1
View File
@@ -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
@@ -88,6 +88,7 @@ func DefaultConfig() *config.Config {
Username: "preferred_username",
Email: "email",
DisplayName: "name",
Groups: "groups",
},
EnableBasicAuth: false,
InsecureBackends: false,
@@ -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
@@ -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
}
+146 -9
View File
@@ -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