diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..ae844b0 --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,53 @@ +package cache + +import ( + "context" + "encoding/json" + + redispools "github.com/cernbox/reva-plugins/redispools" + "github.com/gomodule/redigo/redis" +) + +// store JSON-marshals v and writes it to Redis under key with a TTL in seconds. +// Pass ttlSecs = -1 for no expiry. +func store(pools *redispools.RedisPools, key string, v any, ttlSecs int) error { + encoded, err := json.Marshal(v) + if err != nil { + return err + } + return pools.SetVal(key, string(encoded), ttlSecs) +} + +// fetch retrieves key from Redis and JSON-unmarshals the value into v. +// Returns an error if the key is absent or the value cannot be decoded. +func fetch(ctx context.Context, pools *redispools.RedisPools, key string, v any) error { + raw, err := pools.GetVal(ctx, key) + if err != nil { + return err + } + return json.Unmarshal([]byte(raw), v) +} + +// scanAndFetch runs KEYS followed by MGET and returns the raw +// string values. Duplicate keys are collapsed by Redis itself. An empty +// result set is not an error — callers get back an empty slice. +func scanAndFetch(ctx context.Context, pools *redispools.RedisPools, pattern string) ([]string, error) { + raw, err := pools.DoWithReadFallback(ctx, func(conn redis.Conn) (any, error) { + keys, err := redis.Strings(conn.Do("KEYS", pattern)) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return []string{}, nil + } + args := make([]any, len(keys)) + for i, k := range keys { + args[i] = k + } + return redis.Strings(conn.Do("MGET", args...)) + }) + if err != nil { + return nil, err + } + return raw.([]string), nil +} diff --git a/cache/group.go b/cache/group.go new file mode 100644 index 0000000..ff637ba --- /dev/null +++ b/cache/group.go @@ -0,0 +1,138 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + redispools "github.com/cernbox/reva-plugins/redispools" + grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v3/pkg/appctx" + "github.com/cs3org/reva/v3/pkg/errtypes" +) + +// Key schema: +// group:id: → JSON grouppb.Group +// group:name:_ → bare UUID string +// group:members: → JSON []*userpb.UserId + +const ( + groupIDPrefix = "group:id:" + groupNamePrefix = "group:name:" + groupMembersPrefix = "group:members:" +) + +// GroupCache is a Redis-backed cache for CS3 group objects and their member lists. +type GroupCache struct { + pools *redispools.RedisPools + groupTTLSecs int // 5 × fetch_interval + membersTTLSecs int // member list TTL +} + +// NewGroupCache creates a GroupCache. +// fetchInterval – seconds between full syncs +// membersCacheMinutes – TTL for per-group member lists +func NewGroupCache(pools *redispools.RedisPools, fetchInterval, membersCacheMinutes int) *GroupCache { + return &GroupCache{ + pools: pools, + groupTTLSecs: 5 * fetchInterval, + membersTTLSecs: membersCacheMinutes * 60, + } +} + +// StoreGroup writes a group under all applicable index keys. +func (c *GroupCache) StoreGroup(g *grouppb.Group) error { + if err := store(c.pools, groupIDPrefix+strings.ToLower(g.Id.OpaqueId), g, c.groupTTLSecs); err != nil { + return err + } + if g.DisplayName != "" { + nameKey := groupNamePrefix + + strings.ToLower(g.Id.OpaqueId) + "_" + + strings.ReplaceAll(strings.ToLower(g.DisplayName), " ", "_") + // Store only the UUID; callers resolve it through GetByID. + if err := store(c.pools, nameKey, g.Id.OpaqueId, c.groupTTLSecs); err != nil { + return err + } + } + return nil +} + +// GetByID looks up a group by its OpaqueId. +func (c *GroupCache) GetByID(ctx context.Context, opaqueID string) (*grouppb.Group, error) { + var g grouppb.Group + if err := fetch(ctx, c.pools, groupIDPrefix+strings.ToLower(opaqueID), &g); err != nil { + return nil, errtypes.NotFound(opaqueID) + } + return &g, nil +} + +// GetByName looks up a group by display name. It scans the name index to find +// the UUID, then resolves the full object via GetByID. +func (c *GroupCache) GetByName(ctx context.Context, name string) (*grouppb.Group, error) { + pattern := fmt.Sprintf( + "%s*_%s*", + groupNamePrefix, + strings.ReplaceAll(strings.ToLower(name), " ", "_"), + ) + values, err := scanAndFetch(ctx, c.pools, pattern) + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, errtypes.NotFound(name) + } + // values[0] is the bare UUID stored by StoreGroup. + return c.GetByID(ctx, values[0]) +} + +// Find scans all group index keys matching query and returns the deduplicated +// set of groups. Entries may be either a JSON-encoded Group (from the primary +// key) or a bare UUID string (from the name index); both are handled. +func (c *GroupCache) Find(ctx context.Context, query string) ([]*grouppb.Group, error) { + pattern := fmt.Sprintf( + "group:*%s*", + strings.ReplaceAll(strings.ToLower(query), " ", "_"), + ) + values, err := scanAndFetch(ctx, c.pools, pattern) + if err != nil { + return nil, err + } + + seen := make(map[string]*grouppb.Group) + for _, s := range values { + // Try to decode as a full Group first. + var g grouppb.Group + if err := json.Unmarshal([]byte(s), &g); err == nil && g.Id != nil { + seen[g.Id.OpaqueId] = &g + continue + } + // Otherwise it's a bare UUID from the name index — dereference it. + if full, err := c.GetByID(ctx, s); err == nil { + seen[full.Id.OpaqueId] = full + } + } + + result := make([]*grouppb.Group, 0, len(seen)) + for _, g := range seen { + result = append(result, g) + } + appctx.GetLogger(ctx).Debug().Str("pattern", pattern).Int("results", len(result)).Msg("cache: Find groups") + return result, nil +} + +// StoreMembers writes the member list for a group. +func (c *GroupCache) StoreMembers(gid *grouppb.GroupId, members []*userpb.UserId) error { + return store(c.pools, groupMembersPrefix+strings.ToLower(gid.OpaqueId), members, c.membersTTLSecs) +} + +// GetMembers returns the cached member list for a group, or an error if the +// entry is absent (so the caller can fall back to a live API fetch). +func (c *GroupCache) GetMembers(ctx context.Context, opaqueID string) ([]*userpb.UserId, error) { + var members []*userpb.UserId + if err := fetch(ctx, c.pools, groupMembersPrefix+strings.ToLower(opaqueID), &members); err != nil { + return nil, err + } + return members, nil +} diff --git a/cache/user.go b/cache/user.go new file mode 100644 index 0000000..18c95ff --- /dev/null +++ b/cache/user.go @@ -0,0 +1,187 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + redispools "github.com/cernbox/reva-plugins/redispools" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v3/pkg/appctx" + "github.com/cs3org/reva/v3/pkg/errtypes" +) + +// Key schema: +// user:id: → JSON userpb.User +// user:username: → JSON userpb.User +// user:mail: → JSON userpb.User +// user:name:_ → JSON userpb.User +// user:groups: → JSON []string + +const ( + userIDPrefix = "user:id:" + userUsernamePrefix = "user:username:" + userMailPrefix = "user:mail:" + userNamePrefix = "user:name:" + userGroupsPrefix = "user:groups:" + userIAMUUIDPrefix = "user:iamuuid:" // opaqueID -> IAM account UUID + userOpaqueIDByIAMUUID = "user:byiamuuid:" // IAM account UUID -> opaqueID +) + +// UserCache is a Redis-backed cache for CS3 user objects and their group +// membership lists. +type UserCache struct { + pools *redispools.RedisPools + userTTLSecs int // 5 × fetch_interval + groupsTTLSecs int // group membership TTL +} + +// NewUserCache creates a UserCache. +// +// fetchInterval – seconds between full IAM/GRAPPA syncs (used to +// derive user record TTL as 5× this value) +// groupsCacheMinutes – TTL for per-user group membership lists +func NewUserCache(pools *redispools.RedisPools, fetchInterval, groupsCacheMinutes int) *UserCache { + return &UserCache{ + pools: pools, + userTTLSecs: 5 * fetchInterval, + groupsTTLSecs: groupsCacheMinutes * 60, + } +} + +// StoreUser writes a user under all applicable index keys. +func (c *UserCache) StoreUser(u *userpb.User) error { + if err := store(c.pools, userIDPrefix+strings.ToLower(u.Id.OpaqueId), u, c.userTTLSecs); err != nil { + return err + } + if u.Username != "" { + if err := store(c.pools, userUsernamePrefix+strings.ToLower(u.Username), u, c.userTTLSecs); err != nil { + return err + } + } + if u.Mail != "" { + if err := store(c.pools, userMailPrefix+strings.ToLower(u.Mail), u, c.userTTLSecs); err != nil { + return err + } + } + if u.DisplayName != "" { + nameKey := userNamePrefix + + strings.ToLower(u.Id.OpaqueId) + "_" + + strings.ReplaceAll(strings.ToLower(u.DisplayName), " ", "_") + if err := store(c.pools, nameKey, u, c.userTTLSecs); err != nil { + return err + } + } + return nil +} + +// GetByID looks up a user by their OpaqueId. +func (c *UserCache) GetByID(ctx context.Context, opaqueID string) (*userpb.User, error) { + var u userpb.User + if err := fetch(ctx, c.pools, userIDPrefix+strings.ToLower(opaqueID), &u); err != nil { + return nil, errtypes.NotFound(opaqueID) + } + return &u, nil +} + +// GetByUsername looks up a user by their login name. +func (c *UserCache) GetByUsername(ctx context.Context, username string) (*userpb.User, error) { + var u userpb.User + if err := fetch(ctx, c.pools, userUsernamePrefix+strings.ToLower(username), &u); err != nil { + return nil, errtypes.NotFound(username) + } + return &u, nil +} + +// GetByMail looks up a user by their primary email address. +func (c *UserCache) GetByMail(ctx context.Context, mail string) (*userpb.User, error) { + var u userpb.User + if err := fetch(ctx, c.pools, userMailPrefix+strings.ToLower(mail), &u); err != nil { + return nil, errtypes.NotFound(mail) + } + return &u, nil +} + +// Find scans all user index keys matching query and returns the deduplicated +// set of users. An empty query returns all cached users. +func (c *UserCache) Find(ctx context.Context, query string) ([]*userpb.User, error) { + pattern := fmt.Sprintf( + "user:*%s*", + strings.ReplaceAll(strings.ToLower(query), " ", "_"), + ) + values, err := scanAndFetch(ctx, c.pools, pattern) + if err != nil { + return nil, err + } + + seen := make(map[string]*userpb.User) + for _, s := range values { + var u userpb.User + // Only keep users that have no "inactive" status set (cf. parseAndCacheUser) + if err := json.Unmarshal([]byte(s), &u); err == nil && u.Id != nil && u.Id.OpaqueId != "" && u.Status != userpb.UserStatus_USER_STATUS_EXPIRING { + seen[u.Id.OpaqueId] = &u + } + } + + result := make([]*userpb.User, 0, len(seen)) + for _, u := range seen { + result = append(result, u) + } + appctx.GetLogger(ctx).Debug().Str("pattern", pattern).Int("results", len(result)).Msg("cache: Find users") + return result, nil +} + +// StoreGroups writes the group membership list for a user. +func (c *UserCache) StoreGroups(uid *userpb.UserId, groups []string) error { + return store(c.pools, userGroupsPrefix+strings.ToLower(uid.OpaqueId), groups, c.groupsTTLSecs) +} + +// GetGroups returns the cached group membership list for a user, or an error +// if the entry is absent (so the caller can fall back to a live API fetch). +func (c *UserCache) GetGroups(ctx context.Context, opaqueID string) ([]string, error) { + var groups []string + if err := fetch(ctx, c.pools, userGroupsPrefix+strings.ToLower(opaqueID), &groups); err != nil { + return nil, err + } + return groups, nil +} + +// StoreIAMUUID records the two-way mapping between a remapped OpaqueId and +// the original IAM account UUID, so callers can resolve in either direction: +// - GetIAMUUID: opaqueID -> iamUUID (used by the user driver for +// group-membership lookups against the IAM API) +// - GetOpaqueIDByIAMUUID: iamUUID -> opaqueID (used by the group driver +// to report the same OpaqueId for a member that GetUser/FindUsers +// would report) +// +// Only called for accounts that were actually remapped via primary_users; +// the common case (OpaqueId == IAM UUID) needs no entry in either direction. +func (c *UserCache) StoreIAMUUID(opaqueID, iamUUID string) error { + if err := store(c.pools, userIAMUUIDPrefix+strings.ToLower(opaqueID), iamUUID, c.userTTLSecs); err != nil { + return err + } + return store(c.pools, userOpaqueIDByIAMUUID+strings.ToLower(iamUUID), opaqueID, c.userTTLSecs) +} + +// GetIAMUUID resolves the IAM account UUID for a given OpaqueId. If no +// mapping is present, callers should fall back to treating opaqueID itself +// as the IAM UUID. +func (c *UserCache) GetIAMUUID(ctx context.Context, opaqueID string) (string, error) { + var uuid string + if err := fetch(ctx, c.pools, userIAMUUIDPrefix+strings.ToLower(opaqueID), &uuid); err != nil { + return "", err + } + return uuid, nil +} + +// GetOpaqueIDByIAMUUID resolves the public OpaqueId for a given IAM account +// UUID. If no mapping is present, callers should fall back to treating the +// IAM UUID itself as the OpaqueId (the common, non-remapped case). +func (c *UserCache) GetOpaqueIDByIAMUUID(ctx context.Context, iamUUID string) (string, error) { + var opaqueID string + if err := fetch(ctx, c.pools, userOpaqueIDByIAMUUID+strings.ToLower(iamUUID), &opaqueID); err != nil { + return "", err + } + return opaqueID, nil +} diff --git a/cernbox.go b/cernbox.go index a8c5ff5..fd6b652 100644 --- a/cernbox.go +++ b/cernbox.go @@ -4,9 +4,11 @@ import ( // Add here all the plugins used by cernbox. _ "github.com/cernbox/reva-plugins/cback/http" _ "github.com/cernbox/reva-plugins/cback/storage" - _ "github.com/cernbox/reva-plugins/group" + _ "github.com/cernbox/reva-plugins/group/indigoiam" + _ "github.com/cernbox/reva-plugins/group/rest" _ "github.com/cernbox/reva-plugins/otg" _ "github.com/cernbox/reva-plugins/storage/eoswrapper" _ "github.com/cernbox/reva-plugins/thumbnails" - _ "github.com/cernbox/reva-plugins/user" + _ "github.com/cernbox/reva-plugins/user/indigoiam" + _ "github.com/cernbox/reva-plugins/user/rest" ) diff --git a/group/cache.go b/group/cache.go deleted file mode 100644 index bf91685..0000000 --- a/group/cache.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2018-2026 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package rest - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - "github.com/cs3org/reva/v3/pkg/appctx" - "github.com/gomodule/redigo/redis" -) - -const ( - groupPrefix = "group:" - idPrefix = "id:" - namePrefix = "name:" - gidPrefix = "gid:" - groupMembersPrefix = "members:" - groupInternalIDPrefix = "internal:" -) - -func (m *manager) findCachedGroups(ctx context.Context, query string) ([]*grouppb.Group, error) { - query = fmt.Sprintf("%s*%s*", groupPrefix, strings.ReplaceAll(strings.ToLower(query), " ", "_")) - log := appctx.GetLogger(ctx) - - raw, err := m.redisPools.DoWithReadFallback(ctx, func(conn redis.Conn) (interface{}, error) { - keys, err := redis.Strings(conn.Do("KEYS", query)) - if err != nil { - return nil, err - } - if len(keys) == 0 { - return []string{}, nil - } - args := make([]interface{}, len(keys)) - for i, k := range keys { - args[i] = k - } - return redis.Strings(conn.Do("MGET", args...)) - }) - if err != nil { - return nil, err - } - - groupStrings := raw.([]string) - groupMap := make(map[string]*grouppb.Group) - for _, group := range groupStrings { - g := grouppb.Group{} - if err = json.Unmarshal([]byte(group), &g); err == nil { - groupMap[g.Id.OpaqueId] = &g - } - } - - groups := make([]*grouppb.Group, 0, len(groupMap)) - for _, g := range groupMap { - groups = append(groups, g) - } - log.Debug().Any("query", query).Int("results", len(groups)).Msg("rest: successfully found cached groups") - return groups, nil -} - -func (m *manager) fetchCachedGroupDetails(ctx context.Context, gid *grouppb.GroupId) (*grouppb.Group, error) { - group, err := m.redisPools.GetVal(ctx, groupPrefix+idPrefix+gid.OpaqueId) - if err != nil { - return nil, err - } - - g := grouppb.Group{} - if err = json.Unmarshal([]byte(group), &g); err != nil { - return nil, err - } - return &g, nil -} - -func (m *manager) cacheGroupDetails(g *grouppb.Group) error { - encodedGroup, err := json.Marshal(&g) - if err != nil { - return err - } - if err = m.redisPools.SetVal(groupPrefix+idPrefix+strings.ToLower(g.Id.OpaqueId), string(encodedGroup), 5*m.conf.GroupFetchInterval); err != nil { - return err - } - - if g.GidNumber != 0 { - if err = m.redisPools.SetVal(groupPrefix+gidPrefix+strconv.FormatInt(g.GidNumber, 10), g.Id.OpaqueId, 5*m.conf.GroupFetchInterval); err != nil { - return err - } - } - if g.DisplayName != "" { - if err = m.redisPools.SetVal(groupPrefix+namePrefix+g.Id.OpaqueId+"_"+strings.ToLower(g.DisplayName), g.Id.OpaqueId, 5*m.conf.GroupFetchInterval); err != nil { - return err - } - } - return nil -} - -func (m *manager) fetchCachedGroupByParam(ctx context.Context, field, claim string) (*grouppb.Group, error) { - group, err := m.redisPools.GetVal(ctx, groupPrefix+field+":"+strings.ToLower(claim)) - if err != nil { - return nil, err - } - - g := grouppb.Group{} - if err = json.Unmarshal([]byte(group), &g); err != nil { - return nil, err - } - return &g, nil -} - -func (m *manager) fetchCachedGroupMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) { - members, err := m.redisPools.GetVal(ctx, groupPrefix+groupMembersPrefix+strings.ToLower(gid.OpaqueId)) - if err != nil { - return nil, err - } - u := []*userpb.UserId{} - if err = json.Unmarshal([]byte(members), &u); err != nil { - return nil, err - } - return u, nil -} - -func (m *manager) cacheGroupMembers(gid *grouppb.GroupId, members []*userpb.UserId) error { - u, err := json.Marshal(&members) - if err != nil { - return err - } - return m.redisPools.SetVal(groupPrefix+groupMembersPrefix+strings.ToLower(gid.OpaqueId), string(u), m.conf.GroupMembersCacheExpiration*60) -} diff --git a/group/indigoiam/rest.go b/group/indigoiam/rest.go new file mode 100644 index 0000000..a4412a0 --- /dev/null +++ b/group/indigoiam/rest.go @@ -0,0 +1,409 @@ +// Copyright 2018-2026 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Package indigoiam implements a Reva group provider driver backed by the +// Indigo IAM REST API +// (https://indigo-iam.github.io/v/current/docs/reference/api/account-api/). +package indigoiam + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/cernbox/reva-plugins/cache" + redispools "github.com/cernbox/reva-plugins/redispools" + grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v3" + "github.com/cs3org/reva/v3/pkg/appctx" + "github.com/cs3org/reva/v3/pkg/group" + "github.com/cs3org/reva/v3/pkg/utils/cfg" +) + +func init() { + reva.RegisterPlugin(manager{}) +} + +type manager struct { + conf *config + cache *cache.GroupCache + userCache *cache.UserCache // read-only access, for IAM-UUID -> OpaqueId resolution + httpClient *http.Client +} + +func (manager) RevaPlugin() reva.PluginInfo { + return reva.PluginInfo{ + ID: "grpc.services.groupprovider.drivers.indigo-iam", + New: New, + } +} + +type config struct { + RedisAddress string `mapstructure:"redis_address" docs:"localhost:6379"` + RedisSentinelAddress string `mapstructure:"redis_sentinel_address" docs:""` + RedisUsername string `mapstructure:"redis_username" docs:""` + RedisPassword string `mapstructure:"redis_password" docs:""` + RedisMasterName string `mapstructure:"redis_master_name" docs:""` + RedisSentinelMode bool `mapstructure:"redis_sentinel_mode" docs:"false"` + + IAMBaseURL string `mapstructure:"iam_base_url" docs:"https://iam.example.org"` + AdminToken string `mapstructure:"admin_token" docs:"-"` + IDProvider string `mapstructure:"id_provider" docs:"https://iam.example.org"` + HTTPTimeoutSeconds int `mapstructure:"http_timeout_seconds" docs:"30"` + Insecure bool `mapstructure:"insecure" docs:"false"` + + GroupFetchInterval int `mapstructure:"group_fetch_interval" docs:"3600"` + GroupMembersCacheExpiration int `mapstructure:"group_members_cache_expiration" docs:"5"` + PageSize int `mapstructure:"page_size" docs:"100"` +} + +func (c *config) ApplyDefaults() { + if c.RedisAddress == "" { + c.RedisAddress = ":6379" + } + if c.RedisSentinelAddress == "" { + c.RedisSentinelAddress = c.RedisAddress + } + if c.IAMBaseURL == "" { + c.IAMBaseURL = "https://iam.example.org" + } + if c.IDProvider == "" { + c.IDProvider = c.IAMBaseURL + } + if c.HTTPTimeoutSeconds == 0 { + c.HTTPTimeoutSeconds = 30 + } + if c.GroupFetchInterval == 0 { + c.GroupFetchInterval = 3600 + } + if c.GroupMembersCacheExpiration == 0 { + c.GroupMembersCacheExpiration = 5 + } + if c.PageSize == 0 { + c.PageSize = 100 + } +} + +// --------------------------------------------------------------------------- +// Indigo IAM JSON shapes +// --------------------------------------------------------------------------- + +// iamGroup is the shape of a single resource in /iam/group/search. +type iamGroup struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + IndigoGroup struct { + ParentGroup *struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + } `json:"parentGroup"` + Description string `json:"description"` + } `json:"urn:indigo-dc:scim:schemas:IndigoGroup"` +} + +// iamGroupList is the paginated list returned by /iam/group/search. +type iamGroupList struct { + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Resources []iamGroup `json:"Resources"` +} + +// iamAccount is the shape of a user returned by /iam/account/find/bygroup/{id}. +type iamAccount struct { + ID string `json:"id"` + UserName string `json:"userName"` + Active bool `json:"active"` +} + +// iamAccountList is the paginated list returned by the bygroup filter endpoint. +type iamAccountList struct { + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Resources []iamAccount `json:"Resources"` +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +// New creates and returns a group.Manager backed by Indigo IAM. +func New(ctx context.Context, m map[string]interface{}) (group.Manager, error) { + var c config + if err := cfg.Decode(m, &c); err != nil { + return nil, err + } + c.ApplyDefaults() + + pools, err := redispools.NewRedisPoolsWithSentinelAddress( + ctx, + c.RedisAddress, c.RedisSentinelAddress, + c.RedisUsername, c.RedisPassword, + c.RedisSentinelMode, c.RedisMasterName, + ) + if err != nil { + appctx.GetLogger(ctx).Error().Err(err).Msg("indigoiam group: failed to initialise Redis pools") + pools = &redispools.RedisPools{} + } + + tr := http.DefaultTransport + if c.Insecure { + tr = &http.Transport{} + } + + mgr := &manager{ + conf: &c, + cache: cache.NewGroupCache(pools, c.GroupFetchInterval, c.GroupMembersCacheExpiration), + // UserGroupsCacheExpiration doesn't apply here since this UserCache + // instance is only ever used for GetIAMUUID/GetOpaqueIDByIAMUUID + // lookups, not for storing users or groups membership. The TTL + // arguments only affect StoreUser/StoreGroups, which this driver + // never calls, so any values are safe; we reuse the group driver's + // own fetch interval for consistency. + userCache: cache.NewUserCache(pools, c.GroupFetchInterval, c.GroupMembersCacheExpiration), + httpClient: &http.Client{ + Transport: tr, + Timeout: time.Duration(c.HTTPTimeoutSeconds) * time.Second, + }, + } + + go mgr.fetchAllGroups(context.Background()) + return mgr, nil +} + +// --------------------------------------------------------------------------- +// Background bulk-fetch loop +// --------------------------------------------------------------------------- + +func (m *manager) fetchAllGroups(ctx context.Context) { + log := appctx.GetLogger(ctx) + if err := m.fetchAllGroupAccounts(ctx); err != nil { + log.Error().Err(err).Msg("indigoiam group: initial bulk fetch failed") + } + + ticker := time.NewTicker(time.Duration(m.conf.GroupFetchInterval) * time.Second) + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT) + + for { + select { + case <-sigs: + return + case <-ticker.C: + if err := m.fetchAllGroupAccounts(ctx); err != nil { + log.Error().Err(err).Msg("indigoiam group: periodic bulk fetch failed") + } + } + } +} + +func (m *manager) fetchAllGroupAccounts(ctx context.Context) error { + log := appctx.GetLogger(ctx) + startIndex := 1 + + for { + url := fmt.Sprintf( + "%s/iam/group/search?startIndex=%d&count=%d", + m.conf.IAMBaseURL, startIndex, m.conf.PageSize, + ) + + var list iamGroupList + if err := m.iamGET(ctx, url, &list); err != nil { + return err + } + + for i := range list.Resources { + g := m.iamGroupToProto(&list.Resources[i]) + if err := m.cache.StoreGroup(g); err != nil { + log.Error().Err(err).Str("uuid", list.Resources[i].ID).Msg("indigoiam group: cache error") + } + } + + nextStart := startIndex + list.ItemsPerPage + if nextStart > list.TotalResults { + break + } + startIndex = nextStart + } + return nil +} + +func (m *manager) iamGroupToProto(g *iamGroup) *grouppb.Group { + return &grouppb.Group{ + Id: &grouppb.GroupId{ + OpaqueId: g.ID, + Idp: m.conf.IDProvider, + }, + GroupName: g.DisplayName, + DisplayName: g.DisplayName, + } +} + +// --------------------------------------------------------------------------- +// group.Manager interface +// --------------------------------------------------------------------------- + +func (m *manager) GetGroup(ctx context.Context, gid *grouppb.GroupId, skipFetchingMembers bool) (*grouppb.Group, error) { + g, err := m.cache.GetByID(ctx, gid.OpaqueId) + if err != nil { + return nil, err + } + if !skipFetchingMembers { + members, err := m.GetMembers(ctx, gid) + if err != nil { + return nil, err + } + g.Members = members + } + return g, nil +} + +func (m *manager) GetGroupByClaim(ctx context.Context, claim, value string, skipFetchingMembers bool) (*grouppb.Group, error) { + var g *grouppb.Group + var err error + switch claim { + case "group_name", "name": + g, err = m.cache.GetByName(ctx, value) + default: + g, err = m.cache.GetByID(ctx, value) + } + if err != nil { + return nil, err + } + + if !skipFetchingMembers { + members, err := m.GetMembers(ctx, g.Id) + if err != nil { + return nil, err + } + g.Members = members + } + return g, nil +} + +func (m *manager) FindGroups(ctx context.Context, query string, skipFetchingMembers bool) ([]*grouppb.Group, error) { + parts := strings.SplitN(query, ":", 2) + if len(parts) == 2 { + if parts[0] == "a" { + query = parts[1] + } else { + return []*grouppb.Group{}, nil + } + } + return m.cache.Find(ctx, query) +} + +func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) { + if cached, err := m.cache.GetMembers(ctx, gid.OpaqueId); err == nil { + return cached, nil + } + + startIndex := 1 + var members []*userpb.UserId + + for { + url := fmt.Sprintf( + "%s/iam/account/find/bygroup/%s?startIndex=%d&count=%d", + m.conf.IAMBaseURL, gid.OpaqueId, startIndex, m.conf.PageSize, + ) + + var list iamAccountList + if err := m.iamGET(ctx, url, &list); err != nil { + return nil, err + } + + for _, acc := range list.Resources { + if !acc.Active { + continue + } + + opaqueID := acc.ID + userType := userpb.UserType_USER_TYPE_LIGHTWEIGHT + + if mapped, err := m.userCache.GetOpaqueIDByIAMUUID(ctx, acc.ID); err == nil { + opaqueID = mapped + userType = userpb.UserType_USER_TYPE_PRIMARY + } + + members = append(members, &userpb.UserId{ + OpaqueId: opaqueID, + Idp: m.conf.IDProvider, + Type: userType, + }) + } + + nextStart := startIndex + list.ItemsPerPage + if nextStart > list.TotalResults { + break + } + startIndex = nextStart + } + + if err := m.cache.StoreMembers(gid, members); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Msg("indigoiam group: failed to cache members") + } + return members, nil +} + +func (m *manager) HasMember(ctx context.Context, gid *grouppb.GroupId, uid *userpb.UserId) (bool, error) { + members, err := m.GetMembers(ctx, gid) + if err != nil { + return false, err + } + for _, u := range members { + if u.OpaqueId == uid.OpaqueId { + return true, nil + } + } + return false, nil +} + +// --------------------------------------------------------------------------- +// IAM HTTP helper +// --------------------------------------------------------------------------- + +func (m *manager) iamGET(ctx context.Context, url string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+m.conf.AdminToken) + req.Header.Set("Accept", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("indigoiam: GET %s returned %s: %s", url, resp.Status, string(body)) + } + + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/group/rest.go b/group/rest/rest.go similarity index 93% rename from group/rest.go rename to group/rest/rest.go index aeecdd6..198d599 100644 --- a/group/rest.go +++ b/group/rest/rest.go @@ -27,8 +27,9 @@ import ( "syscall" "time" + "github.com/cernbox/reva-plugins/cache" redispools "github.com/cernbox/reva-plugins/redispools" - user "github.com/cernbox/reva-plugins/user" + userrest "github.com/cernbox/reva-plugins/user/rest" grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" "github.com/cs3org/reva/v3" @@ -46,7 +47,7 @@ func init() { type manager struct { conf *config - redisPools *redispools.RedisPools + cache *cache.GroupCache apiTokenManager *utils.APITokenManager } @@ -136,7 +137,7 @@ func New(ctx context.Context, m map[string]interface{}) (group.Manager, error) { mgr := &manager{ conf: &c, - redisPools: pools, + cache: cache.NewGroupCache(pools, c.GroupFetchInterval, c.GroupMembersCacheExpiration), apiTokenManager: apiTokenManager, } go mgr.fetchAllGroups(context.Background()) @@ -218,7 +219,7 @@ func (m *manager) parseAndCacheGroup(ctx context.Context, g *Group) (*grouppb.Gr GidNumber: int64(g.GID), } - if err := m.cacheGroupDetails(group); err != nil { + if err := m.cache.StoreGroup(group); err != nil { log.Error().Err(err).Msg("rest: error caching group details") } @@ -226,7 +227,7 @@ func (m *manager) parseAndCacheGroup(ctx context.Context, g *Group) (*grouppb.Gr } func (m *manager) GetGroup(ctx context.Context, gid *grouppb.GroupId, skipFetchingMembers bool) (*grouppb.Group, error) { - g, err := m.fetchCachedGroupDetails(ctx, gid) + g, err := m.cache.GetByID(ctx, gid.OpaqueId) if err != nil { return nil, err } @@ -247,7 +248,7 @@ func (m *manager) GetGroupByClaim(ctx context.Context, claim, value string, skip return m.GetGroup(ctx, &grouppb.GroupId{OpaqueId: value}, skipFetchingMembers) } - g, err := m.fetchCachedGroupByParam(ctx, claim, value) + g, err := m.cache.GetByName(ctx, value) if err != nil { return nil, err } @@ -278,25 +279,25 @@ func (m *manager) FindGroups(ctx context.Context, query string, skipFetchingMemb } } - return m.findCachedGroups(ctx, query) + return m.cache.Find(ctx, query) } func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) { - users, err := m.fetchCachedGroupMembers(ctx, gid) + users, err := m.cache.GetMembers(ctx, gid.OpaqueId) if err == nil { return users, nil } url := fmt.Sprintf("%s/api/v1.0/Group/%s/memberidentities/recursive?limit=10&field=upn&field=primaryAccountEmail&field=displayName&field=uid&field=gid&field=type&field=source", m.conf.APIBaseURL, gid.OpaqueId) - var r user.IdentitiesResponse + var r userrest.IdentitiesResponse members := []*userpb.UserId{} for { if err := m.apiTokenManager.SendAPIGetRequest(ctx, url, false, &r); err != nil { return nil, err } - users := list.Map(r.Data, func(i *user.Identity) *userpb.UserId { + users := list.Map(r.Data, func(i *userrest.Identity) *userpb.UserId { return &userpb.UserId{OpaqueId: i.Upn, Idp: m.conf.IDProvider, Type: i.UserType()} }) members = append(members, users...) @@ -307,11 +308,11 @@ func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*user url = fmt.Sprintf("%s%s", m.conf.APIBaseURL, *r.Pagination.Next) } - if err = m.cacheGroupMembers(gid, members); err != nil { + if err = m.cache.StoreMembers(gid, members); err != nil { appctx.GetLogger(ctx).Error().Err(err).Msg("rest: error caching group members") } - return users, nil + return members, nil } func (m *manager) HasMember(ctx context.Context, gid *grouppb.GroupId, uid *userpb.UserId) (bool, error) { diff --git a/user/cache.go b/user/cache.go deleted file mode 100644 index f099e44..0000000 --- a/user/cache.go +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright 2018-2026 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package rest - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - "github.com/cs3org/reva/v3/pkg/appctx" - "github.com/cs3org/reva/v3/pkg/errtypes" - "github.com/gomodule/redigo/redis" -) - -const ( - userPrefix = "user:" - usernamePrefix = "username:" - namePrefix = "name:" - mailPrefix = "mail:" - uidPrefix = "uid:" - userGroupsPrefix = "groups:" -) - -func (m *manager) findCachedUsers(ctx context.Context, query string) ([]*userpb.User, error) { - query = fmt.Sprintf("%s*%s*", userPrefix, strings.ReplaceAll(strings.ToLower(query), " ", "_")) - log := appctx.GetLogger(ctx) - - raw, err := m.redisPools.DoWithReadFallback(ctx, func(conn redis.Conn) (interface{}, error) { - keys, err := redis.Strings(conn.Do("KEYS", query)) - if err != nil { - return nil, err - } - if len(keys) == 0 { - return []string{}, nil - } - args := make([]interface{}, len(keys)) - for i, k := range keys { - args[i] = k - } - return redis.Strings(conn.Do("MGET", args...)) - }) - if err != nil { - return nil, err - } - - userStrings := raw.([]string) - userMap := make(map[string]*userpb.User) - for _, user := range userStrings { - u := userpb.User{} - // Only keep users that have no "inactive" status set (cf. parseAndCacheUser) - if err = json.Unmarshal([]byte(user), &u); err == nil { - if u.Id != nil && u.Id.OpaqueId != "" && u.Status != userpb.UserStatus_USER_STATUS_EXPIRING { - userMap[u.Id.OpaqueId] = &u - } - } - } - - users := make([]*userpb.User, 0, len(userMap)) - for _, u := range userMap { - users = append(users, u) - } - log.Debug().Any("query", query).Int("results", len(users)).Msg("rest: successfully found cached users") - return users, nil -} - -func (m *manager) fetchCachedUserDetails(ctx context.Context, uid *userpb.UserId) (*userpb.User, error) { - user, err := m.redisPools.GetVal(ctx, userPrefix+usernamePrefix+strings.ToLower(uid.OpaqueId)) - if err != nil { - return nil, err - } - - u := userpb.User{} - if err = json.Unmarshal([]byte(user), &u); err != nil { - return nil, err - } - return &u, nil -} - -func (m *manager) cacheUserDetails(u *userpb.User) error { - encodedUser, err := json.Marshal(&u) - if err != nil { - return err - } - if err = m.redisPools.SetVal(userPrefix+usernamePrefix+strings.ToLower(u.Id.OpaqueId), string(encodedUser), 5*m.conf.UserFetchInterval); err != nil { - return err - } - - if u.Mail != "" { - if err = m.redisPools.SetVal(userPrefix+mailPrefix+strings.ToLower(u.Mail), string(encodedUser), 5*m.conf.UserFetchInterval); err != nil { - return err - } - } - if u.DisplayName != "" { - if err = m.redisPools.SetVal(userPrefix+namePrefix+u.Id.OpaqueId+"_"+strings.ReplaceAll(strings.ToLower(u.DisplayName), " ", "_"), string(encodedUser), 5*m.conf.UserFetchInterval); err != nil { - return err - } - } - if u.UidNumber != 0 { - if err = m.redisPools.SetVal(userPrefix+uidPrefix+strconv.FormatInt(u.UidNumber, 10), string(encodedUser), 5*m.conf.UserFetchInterval); err != nil { - return err - } - } - return nil -} - -func (m *manager) fetchCachedUserByParam(ctx context.Context, field, claim string) (*userpb.User, error) { - // We do not want to support linked external accounts - // These can be identified by having username equal to a CERN email - if field == "username" && strings.HasSuffix(claim, "@cern.ch") { - return nil, errtypes.Conflict("linked external accounts are not supported") - } - - user, err := m.redisPools.GetVal(ctx, userPrefix+field+":"+strings.ToLower(claim)) - if err != nil { - return nil, err - } - - u := userpb.User{} - if err = json.Unmarshal([]byte(user), &u); err != nil { - return nil, err - } - return &u, nil -} - -func (m *manager) fetchCachedUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) { - groups, err := m.redisPools.GetVal(ctx, userPrefix+userGroupsPrefix+strings.ToLower(uid.OpaqueId)) - if err != nil { - return nil, err - } - g := []string{} - if err = json.Unmarshal([]byte(groups), &g); err != nil { - return nil, err - } - return g, nil -} - -func (m *manager) cacheUserGroups(uid *userpb.UserId, groups []string) error { - g, err := json.Marshal(&groups) - if err != nil { - return err - } - return m.redisPools.SetVal(userPrefix+userGroupsPrefix+strings.ToLower(uid.OpaqueId), string(g), m.conf.UserGroupsCacheExpiration*60) -} diff --git a/user/indigoiam/rest.go b/user/indigoiam/rest.go new file mode 100644 index 0000000..c472871 --- /dev/null +++ b/user/indigoiam/rest.go @@ -0,0 +1,469 @@ +// Copyright 2018-2026 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +// Package indigoiam implements a Reva user provider driver backed by the +// Indigo IAM REST API +// (https://indigo-iam.github.io/v/current/docs/reference/api/account-api/). +package indigoiam + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cernbox/reva-plugins/cache" + redispools "github.com/cernbox/reva-plugins/redispools" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v3" + "github.com/cs3org/reva/v3/pkg/appctx" + "github.com/cs3org/reva/v3/pkg/user" + "github.com/cs3org/reva/v3/pkg/utils/cfg" +) + +func init() { + reva.RegisterPlugin(manager{}) +} + +type manager struct { + conf *config + cache *cache.UserCache + httpClient *http.Client +} + +type primaryUser struct { + username string + uid_number int64 + gid_number int64 +} + +func (manager) RevaPlugin() reva.PluginInfo { + return reva.PluginInfo{ + ID: "grpc.services.userprovider.drivers.indigo-iam", + New: New, + } +} + +type config struct { + RedisAddress string `mapstructure:"redis_address" docs:"localhost:6379"` + RedisSentinelAddress string `mapstructure:"redis_sentinel_address" docs:""` + RedisUsername string `mapstructure:"redis_username" docs:""` + RedisPassword string `mapstructure:"redis_password" docs:""` + RedisMasterName string `mapstructure:"redis_master_name" docs:""` + RedisSentinelMode bool `mapstructure:"redis_sentinel_mode" docs:"false"` + + IAMBaseURL string `mapstructure:"iam_base_url" docs:"https://iam.example.org"` + AdminToken string `mapstructure:"admin_token" docs:"-"` + IDProvider string `mapstructure:"id_provider" docs:"https://iam.example.org"` + HTTPTimeoutSeconds int `mapstructure:"http_timeout_seconds" docs:"30"` + Insecure bool `mapstructure:"insecure" docs:"false"` + + UserFetchInterval int `mapstructure:"user_fetch_interval" docs:"3600"` + UserGroupsCacheExpiration int `mapstructure:"user_groups_cache_expiration" docs:"5"` + PageSize int `mapstructure:"page_size" docs:"100"` + + // PrimaryUsers maps an IAM account UUID to an internal user with the given + // username, uid and gid, and marks that user as USER_TYPE_PRIMARY. Every other + // user is treated as USER_TYPE_LIGHTWEIGHT. This is needed because IAM + // has no native concept of primary vs. lightweight accounts: by default + // every account looks the same, so an explicit allowlist is required to + // recognize the subset of "real" organization members. + PrimaryUsers map[string]primaryUser `mapstructure:"primary_users" docs:"{}"` +} + +func (c *config) ApplyDefaults() { + if c.RedisAddress == "" { + c.RedisAddress = ":6379" + } + if c.RedisSentinelAddress == "" { + c.RedisSentinelAddress = c.RedisAddress + } + if c.IAMBaseURL == "" { + c.IAMBaseURL = "https://iam.example.org" + } + if c.IDProvider == "" { + c.IDProvider = c.IAMBaseURL + } + if c.HTTPTimeoutSeconds == 0 { + c.HTTPTimeoutSeconds = 30 + } + if c.UserFetchInterval == 0 { + c.UserFetchInterval = 3600 + } + if c.UserGroupsCacheExpiration == 0 { + c.UserGroupsCacheExpiration = 5 + } + if c.PageSize == 0 { + c.PageSize = 100 + } +} + +// --------------------------------------------------------------------------- +// Indigo IAM JSON shapes +// --------------------------------------------------------------------------- + +type iamName struct { + Formatted string `json:"formatted"` + GivenName string `json:"givenName"` + FamilyName string `json:"familyName"` +} + +type iamEmail struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +type iamGroupRef struct { + Display string `json:"display"` + Value string `json:"value"` +} + +// iamAccount is the shape of a single resource in /iam/account/search. +type iamAccount struct { + ID string `json:"id"` + UserName string `json:"userName"` + DisplayName string `json:"displayName"` + Name iamName `json:"name"` + Active bool `json:"active"` + Emails []iamEmail `json:"emails"` + Groups []iamGroupRef `json:"groups"` +} + +func (a *iamAccount) primaryEmail() string { + for _, e := range a.Emails { + if e.Primary { + return e.Value + } + } + if len(a.Emails) > 0 { + return a.Emails[0].Value + } + return "" +} + +// iamAccountList is the paginated list returned by /iam/account/search. +type iamAccountList struct { + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Resources []iamAccount `json:"Resources"` +} + +// iamGroupResource is the shape of a group in /iam/account/{id}/groups. +type iamGroupResource struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Description string `json:"description"` +} + +// iamGroupList is the paginated list returned by /iam/account/{id}/groups. +type iamGroupList struct { + TotalResults int `json:"totalResults"` + Resources []iamGroupResource `json:"Resources"` +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +// New creates and returns a user.Manager backed by Indigo IAM. +func New(ctx context.Context, m map[string]interface{}) (user.Manager, error) { + mgr := &manager{} + if err := mgr.Configure(m, ctx); err != nil { + return nil, err + } + return mgr, nil +} + +func (m *manager) Configure(ml map[string]interface{}, ctx context.Context) error { + var c config + if err := cfg.Decode(ml, &c); err != nil { + return err + } + c.ApplyDefaults() + + pools, err := redispools.NewRedisPoolsWithSentinelAddress( + ctx, + c.RedisAddress, c.RedisSentinelAddress, + c.RedisUsername, c.RedisPassword, + c.RedisSentinelMode, c.RedisMasterName, + ) + if err != nil { + appctx.GetLogger(ctx).Error().Err(err).Msg("indigoiam user: failed to initialise Redis pools") + pools = &redispools.RedisPools{} + } + + tr := http.DefaultTransport + if c.Insecure { + tr = &http.Transport{} + } + + m.conf = &c + m.cache = cache.NewUserCache(pools, c.UserFetchInterval, c.UserGroupsCacheExpiration) + m.httpClient = &http.Client{ + Transport: tr, + Timeout: time.Duration(c.HTTPTimeoutSeconds) * time.Second, + } + + go m.fetchAllUsers(context.Background()) + return nil +} + +// --------------------------------------------------------------------------- +// Background bulk-fetch loop +// --------------------------------------------------------------------------- + +func (m *manager) fetchAllUsers(ctx context.Context) { + log := appctx.GetLogger(ctx) + if err := m.fetchAllUserAccounts(ctx); err != nil { + log.Error().Err(err).Msg("indigoiam user: initial bulk fetch failed") + } + + ticker := time.NewTicker(time.Duration(m.conf.UserFetchInterval) * time.Second) + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT) + + for { + select { + case <-sigs: + return + case <-ticker.C: + if err := m.fetchAllUserAccounts(ctx); err != nil { + log.Error().Err(err).Msg("indigoiam user: periodic bulk fetch failed") + } + } + } +} + +func (m *manager) fetchAllUserAccounts(ctx context.Context) error { + log := appctx.GetLogger(ctx) + startIndex := 1 + + for { + url := fmt.Sprintf( + "%s/iam/account/search?startIndex=%d&count=%d&sortBy=name&sortDirection=asc", + m.conf.IAMBaseURL, startIndex, m.conf.PageSize, + ) + + var list iamAccountList + if err := m.iamGET(ctx, url, &list); err != nil { + return err + } + + for i := range list.Resources { + acc := &list.Resources[i] + if !acc.Active { + continue + } + u, remapped := m.accountToProto(acc) + if err := m.cache.StoreUser(u); err != nil { + log.Error().Err(err).Str("uuid", acc.ID).Msg("indigoiam user: cache error") + } + // Only the exceptional, remapped case needs a reverse-index + // entry; lightweight users already have OpaqueId == IAM UUID. + if remapped { + if err := m.cache.StoreIAMUUID(u.Id.OpaqueId, acc.ID); err != nil { + log.Error().Err(err).Str("uuid", acc.ID).Msg("indigoiam user: failed to cache IAM UUID mapping") + } + } + } + + nextStart := startIndex + list.ItemsPerPage + if nextStart > list.TotalResults { + break + } + startIndex = nextStart + } + return nil +} + +// accountToProto converts an iamAccount to the CS3 userpb.User type. +// If acc.ID is a key in conf.PrimaryUsers, the OpaqueId is replaced by the +// mapped value and the user is marked PRIMARY; otherwise the user is +// LIGHTWEIGHT and keeps its original IAM UUID as OpaqueId. +func (m *manager) accountToProto(acc *iamAccount) (*userpb.User, bool) { + opaqueID := acc.ID + userType := userpb.UserType_USER_TYPE_LIGHTWEIGHT + uid := int64(0) + gid := int64(0) + remapped := false + + if mapped, remapped := m.conf.PrimaryUsers[acc.ID]; remapped { + opaqueID = mapped.username + uid = int64(mapped.uid_number) + gid = int64(mapped.gid_number) + userType = userpb.UserType_USER_TYPE_PRIMARY + } + + return &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: opaqueID, + Idp: m.conf.IDProvider, + Type: userType, + }, + Username: acc.UserName, + Mail: acc.primaryEmail(), + DisplayName: acc.DisplayName, + UidNumber: uid, + GidNumber: gid, + }, remapped +} + +// --------------------------------------------------------------------------- +// user.Manager interface +// --------------------------------------------------------------------------- + +func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId, skipFetchingGroups bool) (*userpb.User, error) { + u, err := m.cache.GetByID(ctx, uid.OpaqueId) + if err != nil { + return nil, err + } + if !skipFetchingGroups { + groups, err := m.GetUserGroups(ctx, uid) + if err != nil { + return nil, err + } + u.Groups = groups + } + return u, nil +} + +func (m *manager) GetUserByClaim(ctx context.Context, claim, value string, skipFetchingGroups bool) (*userpb.User, error) { + var u *userpb.User + var err error + switch claim { + case "username": + u, err = m.cache.GetByUsername(ctx, value) + case "mail": + u, err = m.cache.GetByMail(ctx, value) + default: + u, err = m.cache.GetByID(ctx, value) + } + if err != nil { + return nil, err + } + + if !skipFetchingGroups { + groups, err := m.GetUserGroups(ctx, u.Id) + if err != nil { + return nil, err + } + u.Groups = groups + } + return u, nil +} + +func (m *manager) FindUsers(ctx context.Context, query string, filters []*userpb.Filter, skipFetchingGroups bool) ([]*userpb.User, error) { + users, err := m.cache.Find(ctx, query) + if err != nil { + return nil, err + } + + if filters == nil { + return users, nil + } + + result := make([]*userpb.User, 0, len(users)) + for _, u := range users { + ok := true + for _, f := range filters { + if !user.DoesUserFulfillFilterCriteria(u, f) { + ok = false + break + } + } + if ok { + result = append(result, u) + } + } + return result, nil +} + +func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) { + if cached, err := m.cache.GetGroups(ctx, uid.OpaqueId); err == nil { + return cached, nil + } + + iamUUID, err := m.cache.GetIAMUUID(ctx, uid.OpaqueId) + if err != nil { + // No mapping found — assume the OpaqueId is already the IAM UUID + // (this is always true for lightweight users, since they are never + // remapped). + iamUUID = uid.OpaqueId + } + + url := fmt.Sprintf("%s/iam/account/%s/groups", m.conf.IAMBaseURL, iamUUID) + var list iamGroupList + if err := m.iamGET(ctx, url, &list); err != nil { + return nil, err + } + + groups := make([]string, 0, len(list.Resources)) + for _, g := range list.Resources { + groups = append(groups, g.Name) + } + + if err := m.cache.StoreGroups(uid, groups); err != nil { + appctx.GetLogger(ctx).Error().Err(err).Msg("indigoiam user: failed to cache user groups") + } + return groups, nil +} + +func (m *manager) IsInGroup(ctx context.Context, uid *userpb.UserId, group string) (bool, error) { + groups, err := m.GetUserGroups(ctx, uid) + if err != nil { + return false, err + } + for _, g := range groups { + if g == group { + return true, nil + } + } + return false, nil +} + +// --------------------------------------------------------------------------- +// IAM HTTP helper +// --------------------------------------------------------------------------- + +func (m *manager) iamGET(ctx context.Context, url string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+m.conf.AdminToken) + req.Header.Set("Accept", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("indigoiam: GET %s returned %s: %s", url, resp.Status, string(body)) + } + + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/user/rest.go b/user/rest/rest.go similarity index 93% rename from user/rest.go rename to user/rest/rest.go index 97cdb06..4269306 100644 --- a/user/rest.go +++ b/user/rest/rest.go @@ -29,6 +29,7 @@ import ( "syscall" "time" + "github.com/cernbox/reva-plugins/cache" redispools "github.com/cernbox/reva-plugins/redispools" "github.com/cernbox/reva-plugins/utils" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" @@ -47,7 +48,7 @@ func init() { type manager struct { conf *config - redisPools *redispools.RedisPools + cache *cache.UserCache apiTokenManager *revautils.APITokenManager } @@ -146,7 +147,7 @@ func (m *manager) Configure(ml map[string]interface{}, ctx context.Context) erro return err } m.conf = &c - m.redisPools = pools + m.cache = cache.NewUserCache(pools, c.UserFetchInterval, c.UserGroupsCacheExpiration) m.apiTokenManager = apiTokenManager // Since we're starting a subroutine which would take some time to execute, @@ -305,7 +306,7 @@ func (m *manager) parseAndCacheUser(ctx context.Context, i *Identity) (*userpb.U u.Status = userpb.UserStatus_USER_STATUS_EXPIRING // check if the user was active before: if so, notify the lifecycle manager that the user has left CERN - cachedUser, err := m.fetchCachedUserDetails(ctx, u.Id) + cachedUser, err := m.cache.GetByID(ctx, u.Id.OpaqueId) if err != nil { log.Error().Err(err).Str("user", u.Username).Msg("rest: error fetching cached user details to check if the user has left CERN") } else { @@ -318,7 +319,7 @@ func (m *manager) parseAndCacheUser(ctx context.Context, i *Identity) (*userpb.U } } - if err := m.cacheUserDetails(u); err != nil { + if err := m.cache.StoreUser(u); err != nil { log.Error().Err(err).Msg("rest: error caching user details") } @@ -354,7 +355,7 @@ func (m *manager) notifyLifecycleManager(ctx context.Context, user *userpb.User) } func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId, skipFetchingGroups bool) (*userpb.User, error) { - u, err := m.fetchCachedUserDetails(ctx, uid) + u, err := m.cache.GetByID(ctx, uid.OpaqueId) if err != nil { if uid == nil || uid.Type != userpb.UserType_USER_TYPE_LIGHTWEIGHT || !strings.Contains(uid.OpaqueId, "@") { return nil, err @@ -378,7 +379,13 @@ func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId, skipFetchingG } func (m *manager) GetUserByClaim(ctx context.Context, claim, value string, skipFetchingGroups bool) (*userpb.User, error) { - u, err := m.fetchCachedUserByParam(ctx, claim, value) + // We do not want to support linked external accounts. + // These can be identified by having username equal to a CERN email. + if claim == "username" && strings.HasSuffix(value, "@cern.ch") { + return nil, fmt.Errorf("rest: linked external accounts are not supported") + } + + u, err := m.getCachedUserByClaim(ctx, claim, value) if err != nil { if _, ok := err.(errtypes.Conflict); ok { return nil, err @@ -404,6 +411,18 @@ func (m *manager) GetUserByClaim(ctx context.Context, claim, value string, skipF return u, nil } +// getCachedUserByClaim dispatches to the right UserCache getter based on claim. +func (m *manager) getCachedUserByClaim(ctx context.Context, claim, value string) (*userpb.User, error) { + switch claim { + case "username": + return m.cache.GetByUsername(ctx, value) + case "mail": + return m.cache.GetByMail(ctx, value) + default: + return m.cache.GetByID(ctx, value) + } +} + func (m *manager) FindUsers(ctx context.Context, query string, filters []*userpb.Filter, skipFetchingGroups bool) ([]*userpb.User, error) { // Look at namespaces filters. If the query starts with: // "a" => look into primary/secondary/service accounts @@ -418,7 +437,7 @@ func (m *manager) FindUsers(ctx context.Context, query string, filters []*userpb namespace, query = parts[0], parts[1] } - users, err := m.findCachedUsers(ctx, query) + users, err := m.cache.Find(ctx, query) if err != nil { return nil, err } @@ -486,7 +505,7 @@ type GroupsResponse struct { } func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) { - cachedGroups, err := m.fetchCachedUserGroups(ctx, uid) + cachedGroups, err := m.cache.GetGroups(ctx, uid.OpaqueId) if err == nil { return cachedGroups, nil } @@ -520,7 +539,7 @@ func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]stri groups.Add(fetchedGroups...) } - if err := m.cacheUserGroups(uid, groups.Values()); err != nil { + if err := m.cache.StoreGroups(uid, groups.Values()); err != nil { log := appctx.GetLogger(ctx) if m.conf.RedisSentinelMode { log.Error().Str("sentinelAddress", m.conf.RedisSentinelAddress).Msg("rest: error caching user groups in Sentinel mode, check Sentinel connectivity and master discovery") diff --git a/utils/tokenmanagement.go b/utils/tokenmanagement.go index 40c4e1b..60565b2 100644 --- a/utils/tokenmanagement.go +++ b/utils/tokenmanagement.go @@ -55,6 +55,7 @@ type config struct { ClientSecret string `mapstructure:"client_secret"` Timeout int64 `mapstructure:"timeout"` Insecure bool `mapstructure:"insecure"` + Scope string `mapstructure:"scope"` } // InitAPITokenManager initializes a new APITokenManager. @@ -98,6 +99,7 @@ func (a *APITokenManager) getAPIToken(ctx context.Context) (string, time.Time, e params := url.Values{ "grant_type": {"client_credentials"}, "audience": {a.conf.TargetAPI}, + "scope": {a.conf.Scope}, } httpReq, err := http.NewRequest(http.MethodPost, a.conf.OIDCTokenEndpoint, strings.NewReader(params.Encode()))