Skip to content

Commit

Permalink
autoprovisioning: sync group memberships
Browse files Browse the repository at this point in the history
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: owncloud#5538
  • Loading branch information
rhafer committed Jul 4, 2024
1 parent aa6041a commit 5833fc5
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 9 deletions.
7 changes: 7 additions & 0 deletions changelog/unreleased/autoprovsion-groups.md
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions services/proxy/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func DefaultConfig() *config.Config {
Username: "preferred_username",
Email: "email",
DisplayName: "name",
Groups: "groups",
},
EnableBasicAuth: false,
InsecureBackends: false,
Expand Down
22 changes: 22 additions & 0 deletions services/proxy/pkg/middleware/account_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
Expand All @@ -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,
}
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/user/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
155 changes: 146 additions & 9 deletions services/proxy/pkg/user/backend/cs3.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package backend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -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"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -243,26 +249,157 @@ func (c cs3backend) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, clai
return nil
}

func (c cs3backend) updateLibregraphUser(userid string, user libregraph.User) error {
// 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()
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, token)
if err != nil {
c.logger.Error().Err(err).Msg("Error setting up libregraph client")
return err
}

lgClient, err := c.setupLibregraphClient(newctx, authRes.GetToken())
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 {
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
Expand Down

0 comments on commit 5833fc5

Please sign in to comment.