mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-05-24 22:19:09 -05:00
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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user