From 67860ef9857868a2699a42b6505a641d17490dfb Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Sun, 14 Jun 2026 17:14:38 +0200 Subject: [PATCH 1/4] First edition of a user/group provider based on Indigo IAM All AI-coded, to be tested --- group/indigoiam/indigoiam.go | 331 ++++++++++++++++++++++++++++++++ group/{ => rest}/rest.go | 0 user/indigoiam/indigoiam.go | 352 +++++++++++++++++++++++++++++++++++ user/{ => rest}/rest.go | 0 4 files changed, 683 insertions(+) create mode 100644 group/indigoiam/indigoiam.go rename group/{ => rest}/rest.go (100%) create mode 100644 user/indigoiam/indigoiam.go rename user/{ => rest}/rest.go (100%) diff --git a/group/indigoiam/indigoiam.go b/group/indigoiam/indigoiam.go new file mode 100644 index 0000000..36d6433 --- /dev/null +++ b/group/indigoiam/indigoiam.go @@ -0,0 +1,331 @@ +// 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. + +// A Group provider following the Indigo IAM REST API +// See https://indigo-iam.github.io/v/v1.14.0/docs/reference/api/account-api/ + +package indigoiam + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + 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/group" + "github.com/go-redis/redis/v8" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +func init() { + reva.RegisterPlugin(manager{}) +} + +type manager struct { + conf *config + client *http.Client + redisClient *redis.Client +} + +func (manager) RevaPlugin() reva.PluginInfo { + return reva.PluginInfo{ + ID: "grpc.services.groupprovider.drivers.indigoiam", + New: New, + } +} + +// config maps the configuration fields for the Indigo IAM group provider +type config struct { + Endpoint string `mapstructure:"endpoint"` + ClientToken string `mapstructure:"client_token"` // Admin token with `iam:admin.read` scope + Idp string `mapstructure:"idp"` + RedisAddress string `mapstructure:"redis_address"` + RedisPassword string `mapstructure:"redis_password"` + RedisDB int `mapstructure:"redis_db"` + CacheTTL time.Duration `mapstructure:"cache_ttl"` +} + +// IAMGroup represents the JSON structure from the Indigo IAM /iam/group/search API +type IAMGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// IAMMember represents a simplified account identity when resolving memberships +type IAMMember struct { + ID string `json:"id"` + Username string `json:"username"` +} + +// New returns a new group.Manager interacting with Indigo IAM +func New(ctx context.Context, m map[string]any) (group.Manager, error) { + c := &config{} + if err := mapstructure.Decode(m, c); err != nil { + return nil, errors.Wrap(err, "error decoding configuration") + } + + if c.Endpoint == "" { + return nil, errors.New("indigo iam group provider: missing endpoint configuration") + } + if c.ClientToken == "" { + return nil, errors.New("indigo iam group provider: missing client token configuration") + } + if c.CacheTTL == 0 { + c.CacheTTL = 5 * time.Minute + } + + var rClient *redis.Client + if c.RedisAddress != "" { + rClient = redis.NewClient(&redis.Options{ + Addr: c.RedisAddress, + Password: c.RedisPassword, + DB: c.RedisDB, + }) + } + + return &manager{ + conf: c, + client: &http.Client{Timeout: 10 * time.Second}, + redisClient: rClient, + }, nil +} + +func (m *manager) Configure(ml map[string]interface{}) error { + return nil +} + +// GetGroup fetches group details by its unique identifier +func (m *manager) GetGroup(ctx context.Context, gid *grouppb.GroupId) (*grouppb.Group, error) { + if gid == nil || gid.OpaqueId == "" { + return nil, errors.New("indigo iam group provider: empty group id") + } + + cacheKey := "group:id:" + gid.OpaqueId + if m.redisClient != nil { + if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { + var g grouppb.Group + if err := json.Unmarshal([]byte(val), &g); err == nil { + return &g, nil + } + } + } + + // Fetch groups and isolate the one matching target identifier + url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) + var groups []IAMGroup + if err := m.doRequest(ctx, url, &groups); err != nil { + return nil, err + } + + for _, g := range groups { + if g.ID == gid.OpaqueId { + targetGroup := m.iamGroupToGroup(&g) + m.storeInCache(ctx, cacheKey, targetGroup) + return targetGroup, nil + } + } + + return nil, errors.New("group not found") +} + +// GetGroupByClaim performs unique lookups using attributes like the group's name +func (m *manager) GetGroupByClaim(ctx context.Context, claim, value string) (*grouppb.Group, error) { + if claim == "" || value == "" { + return nil, errors.New("indigo iam group provider: empty claim or value") + } + + if claim != "name" && claim != "groupname" { + return nil, fmt.Errorf("indigo iam group provider: unsupported claim: %s", claim) + } + + cacheKey := "group:name:" + value + if m.redisClient != nil { + if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { + var g grouppb.Group + if err := json.Unmarshal([]byte(val), &g); err == nil { + return &g, nil + } + } + } + + url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) + var groups []IAMGroup + if err := m.doRequest(ctx, url, &groups); err != nil { + return nil, err + } + + for _, g := range groups { + if g.Name == value { + targetGroup := m.iamGroupToGroup(&g) + m.storeInCache(ctx, cacheKey, targetGroup) + return targetGroup, nil + } + } + + return nil, fmt.Errorf("group not found with claim %s: %s", claim, value) +} + +// FindGroups searches for lists of groups matching a query string fraction +func (m *manager) FindGroups(ctx context.Context, query string) ([]*grouppb.Group, error) { + url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) + var iamGroups []IAMGroup + if err := m.doRequest(ctx, url, &iamGroups); err != nil { + return nil, err + } + + var groups []*grouppb.Group + for _, ig := range iamGroups { + // Basic case-insensitive substring matching if query is provided + if query == "" || legacyContains(ig.Name, query) { + groups = append(groups, m.iamGroupToGroup(&ig)) + } + } + + return groups, nil +} + +// GetMembers evaluates and lists members assigned to the requested group ID +func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) { + if gid == nil || gid.OpaqueId == "" { + return nil, errors.New("indigo iam group provider: empty group id") + } + + cacheKey := "group:members:" + gid.OpaqueId + if m.redisClient != nil { + if cached, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { + var ids []*userpb.UserId + if err := json.Unmarshal([]byte(cached), &ids); err == nil { + return ids, nil + } + } + } + + // Resolve target group identity first to obtain its explicit human name + g, err := m.GetGroup(ctx, gid) + if err != nil { + return nil, err + } + + // Leverage Account query searching filtering matching users linked directly to the group name + url := fmt.Sprintf("%s/iam/account/search?filter=group:%s", m.conf.Endpoint, g.GroupName) + var members []IAMMember + if err := m.doRequest(ctx, url, &members); err != nil { + return nil, err + } + + userIds := make([]*userpb.UserId, len(members)) + for i, mber := range members { + userIds[i] = &userpb.UserId{ + OpaqueId: mber.ID, + Idp: m.conf.Idp, + Type: userpb.UserType_USER_TYPE_PRIMARY, + } + } + + if m.redisClient != nil && len(userIds) > 0 { + if data, err := json.Marshal(userIds); err == nil { + _ = m.redisClient.Set(ctx, cacheKey, data, m.conf.CacheTTL).Err() + } + } + + return userIds, nil +} + +// HasMember checks if a specific user identifier belongs within a target group context +func (m *manager) HasMember(ctx context.Context, gid *grouppb.GroupId, uid *userpb.UserId) (bool, error) { + if gid == nil || uid == nil { + return false, errors.New("indigo iam group provider: invalid group or user identity") + } + + members, err := m.GetMembers(ctx, gid) + if err != nil { + return false, err + } + + for _, member := range members { + if member.OpaqueId == uid.OpaqueId { + return true, nil + } + } + + return false, nil +} + +// Internal implementation helpers + +func (m *manager) doRequest(ctx context.Context, url string, target interface{}) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return errors.Wrap(err, "failed to build request context") + } + + req.Header.Set("Authorization", "Bearer "+m.conf.ClientToken) + req.Header.Set("Accept", "application/json") + + resp, err := m.client.Do(req) + if err != nil { + return errors.Wrap(err, "http execution failed against endpoint") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return errors.New("group entity resource not found") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected upstream api reply status: %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (m *manager) iamGroupToGroup(g *IAMGroup) *grouppb.Group { + return &grouppb.Group{ + Id: &grouppb.GroupId{ + OpaqueId: g.ID, + Idp: m.conf.Idp, + }, + GroupName: g.Name, + DisplayName: g.Name, + } +} + +func (m *manager) storeInCache(ctx context.Context, directKey string, g *grouppb.Group) { + if m.redisClient == nil { + return + } + data, err := json.Marshal(g) + if err != nil { + return + } + + pipe := m.redisClient.Pipeline() + pipe.Set(ctx, directKey, data, m.conf.CacheTTL) + pipe.Set(ctx, "group:id:"+g.Id.OpaqueId, data, m.conf.CacheTTL) + pipe.Set(ctx, "group:name:"+g.GroupName, data, m.conf.CacheTTL) + _, _ = pipe.Exec(ctx) +} + +func legacyContains(s, substr string) bool { + // Simple matching layer mirroring standard lower case lookups + return len(s) >= len(substr) && (s == substr || s[:len(substr)] == substr) +} diff --git a/group/rest.go b/group/rest/rest.go similarity index 100% rename from group/rest.go rename to group/rest/rest.go diff --git a/user/indigoiam/indigoiam.go b/user/indigoiam/indigoiam.go new file mode 100644 index 0000000..87dd17e --- /dev/null +++ b/user/indigoiam/indigoiam.go @@ -0,0 +1,352 @@ +// 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. + +// A user provider following the Indigo IAM REST API +// See https://indigo-iam.github.io/v/v1.14.0/docs/reference/api/account-api/ + +package indigoiam + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/cs3org/reva/v3" + "github.com/cs3org/reva/v3/pkg/user" + "github.com/go-redis/redis/v8" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +func init() { + reva.RegisterPlugin(manager{}) +} + +type manager struct { + conf *config + client *http.Client + redisClient *redis.Client +} + +func (manager) RevaPlugin() reva.PluginInfo { + return reva.PluginInfo{ + ID: "grpc.services.userprovider.drivers.indigoiam", + New: New, + } +} + +// config maps the configuration fields for the Indigo IAM user provider +type config struct { + Endpoint string `mapstructure:"endpoint"` + ClientToken string `mapstructure:"client_token";doc:"Admin token with iam:admin.read scope"` + Idp string `mapstructure:"idp"` + PrimaryUsers []string `mapstructure:"primary_users";default:"[]";doc:"A list of user IDs that should be treated as primary users. If empty, everyone is considered primary."` + RedisAddress string `mapstructure:"redis_address"` + RedisPassword string `mapstructure:"redis_password"` + RedisDB int `mapstructure:"redis_db"` + CacheTTL time.Duration `mapstructure:"cache_ttl"` +} + +// IAMAccount represents the JSON payload structure from the Indigo IAM account/search API +type IAMAccount struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` +} + +// IAMGroup represents the group payload from /iam/account/{id}/groups +type IAMGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// New returns a new user.Manager interacting with Indigo IAM +func New(ctx context.Context, m map[string]any) (user.Manager, error) { + c := &config{} + if err := mapstructure.Decode(m, c); err != nil { + return nil, errors.Wrap(err, "error decoding configuration") + } + + if c.Endpoint == "" { + return nil, errors.New("indigo iam provider: missing endpoint configuration") + } + if c.ClientToken == "" { + return nil, errors.New("indigo iam provider: missing client token configuration") + } + if c.CacheTTL == 0 { + c.CacheTTL = 5 * time.Minute + } + + var rClient *redis.Client + if c.RedisAddress != "" { + rClient = redis.NewClient(&redis.Options{ + Addr: c.RedisAddress, + Password: c.RedisPassword, + DB: c.RedisDB, + }) + } + + return &manager{ + conf: c, + client: &http.Client{Timeout: 10 * time.Second}, + redisClient: rClient, + }, nil +} + +func (m *manager) Configure(ml map[string]interface{}) error { + return nil +} + +// GetUser fetches user information by unique account identifier +func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId, skipFetchingGroups bool) (*userpb.User, error) { + if uid == nil || uid.OpaqueId == "" { + return nil, errors.New("indigo iam provider: empty user id") + } + + cacheKey := "user:id:" + uid.OpaqueId + if m.redisClient != nil { + if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { + var u userpb.User + if err := json.Unmarshal([]byte(val), &u); err == nil { + if !skipFetchingGroups && len(u.Groups) == 0 { + groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) + if err == nil { + u.Groups = groups + } + } + return &u, nil + } + } + } + + // Leverage /iam/account/search endpoint targeting the primary unique ID + url := fmt.Sprintf("%s/iam/account/search?Id=%s", m.conf.Endpoint, uid.OpaqueId) + var accounts []IAMAccount + if err := m.doRequest(ctx, url, &accounts); err != nil { + return nil, err + } + + if len(accounts) == 0 { + return nil, errors.New("user not found") + } + + u := m.iamAccountToUser(&accounts[0]) + + if !skipFetchingGroups { + groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) + if err == nil { + u.Groups = groups + } + } + + m.storeInCache(ctx, cacheKey, u) + return u, nil +} + +// GetUserByClaim looks up a user matching properties like username or mail fields +func (m *manager) GetUserByClaim(ctx context.Context, claim, value, tenantID string, skipFetchingGroups bool) (*userpb.User, error) { + if claim == "" || value == "" { + return nil, errors.New("indigo iam provider: empty claim or value") + } + + var searchParam string + switch claim { + case "username": + searchParam = "username" + case "mail", "email": + searchParam = "email" + default: + return nil, fmt.Errorf("indigo iam provider: unsupported lookup claim: %s", claim) + } + + cacheKey := fmt.Sprintf("user:%s:%s", searchParam, value) + if m.redisClient != nil { + if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { + var u userpb.User + if err := json.Unmarshal([]byte(val), &u); err == nil { + if !skipFetchingGroups && len(u.Groups) == 0 { + groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) + if err == nil { + u.Groups = groups + } + } + return &u, nil + } + } + } + + url := fmt.Sprintf("%s/iam/account/search?%s=%s", m.conf.Endpoint, searchParam, value) + var accounts []IAMAccount + if err := m.doRequest(ctx, url, &accounts); err != nil { + return nil, err + } + + if len(accounts) == 0 { + return nil, fmt.Errorf("user not found with %s: %s", claim, value) + } + + u := m.iamAccountToUser(&accounts[0]) + + if !skipFetchingGroups { + groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) + if err == nil { + u.Groups = groups + } + } + + m.storeInCache(ctx, cacheKey, u) + return u, nil +} + +// FindUsers searches for subsets of accounts matching names, mail handles or usernames +func (m *manager) FindUsers(ctx context.Context, query, tenantID string, skipFetchingGroups bool) ([]*userpb.User, error) { + // Attempt matching on full/display names first + url := fmt.Sprintf("%s/iam/account/search?name=%s", m.conf.Endpoint, query) + var accounts []IAMAccount + if err := m.doRequest(ctx, url, &accounts); err != nil { + return nil, err + } + + // Fallback lookup strategy by username if generic query yielded nothing + if len(accounts) == 0 { + url = fmt.Sprintf("%s/iam/account/search?username=%s", m.conf.Endpoint, query) + if err := m.doRequest(ctx, url, &accounts); err != nil { + return nil, err + } + } + + users := make([]*userpb.User, 0, len(accounts)) + for _, acc := range accounts { + u := m.iamAccountToUser(&acc) + if !skipFetchingGroups { + groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) + if err == nil { + u.Groups = groups + } + } + users = append(users, u) + } + + return users, nil +} + +// GetUserGroups resolves user identities directly to assigned string groups +func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) { + if uid == nil || uid.OpaqueId == "" { + return nil, errors.New("indigo iam provider: empty user id") + } + return m.fetchUserGroups(ctx, uid.OpaqueId) +} + +// Internal implementation helpers + +func (m *manager) doRequest(ctx context.Context, url string, target interface{}) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return errors.Wrap(err, "failed to build request context") + } + + req.Header.Set("Authorization", "Bearer "+m.conf.ClientToken) + req.Header.Set("Accept", "application/json") + + resp, err := m.client.Do(req) + if err != nil { + return errors.Wrap(err, "http execution failed against endpoint") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return errors.New("identity entity not found mapping resource") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected upstream api reply status: %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +func (m *manager) fetchUserGroups(ctx context.Context, userID string) ([]string, error) { + cacheKey := "user:groups:" + userID + if m.redisClient != nil { + if groups, err := m.redisClient.SMembers(ctx, cacheKey).Result(); err == nil && len(groups) > 0 { + return groups, nil + } + } + + url := fmt.Sprintf("%s/iam/account/%s/groups", m.conf.Endpoint, userID) + var iamGroups []IAMGroup + if err := m.doRequest(ctx, url, &iamGroups); err != nil { + return nil, err + } + + groups := make([]string, len(iamGroups)) + for i, g := range iamGroups { + groups[i] = g.Name + } + + if m.redisClient != nil && len(groups) > 0 { + pipe := m.redisClient.Pipeline() + pipe.SAdd(ctx, cacheKey, groups) + pipe.Expire(ctx, cacheKey, m.conf.CacheTTL) + _, _ = pipe.Exec(ctx) + } + + return groups, nil +} + +func (m *manager) iamAccountToUser(account *IAMAccount) *userpb.User { + var ut userpb.UserType + if m.conf.PrimaryUsers == nil || stringInSlice(account.ID, m.conf.PrimaryUsers) { + ut = userpb.UserType_USER_TYPE_PRIMARY + } else { + ut = userpb.UserType_USER_TYPE_LIGHTWEIGHT + } + return &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: account.ID, + Idp: m.conf.Idp, + Type: ut, + }, + Username: account.Username, + Mail: account.Email, + DisplayName: account.Name, + } +} + +func (m *manager) storeInCache(ctx context.Context, directKey string, u *userpb.User) { + if m.redisClient == nil { + return + } + data, err := json.Marshal(u) + if err != nil { + return + } + + pipe := m.redisClient.Pipeline() + pipe.Set(ctx, directKey, data, m.conf.CacheTTL) + pipe.Set(ctx, "user:id:"+u.Id.OpaqueId, data, m.conf.CacheTTL) + pipe.Set(ctx, "user:username:"+u.Username, data, m.conf.CacheTTL) + if u.Mail != "" { + pipe.Set(ctx, "user:email:"+u.Mail, data, m.conf.CacheTTL) + } + _, _ = pipe.Exec(ctx) +} diff --git a/user/rest.go b/user/rest/rest.go similarity index 100% rename from user/rest.go rename to user/rest/rest.go From 90dd1c0d20cbeed4679c3bc5186ca69b718225d8 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 17 Jun 2026 07:44:15 +0200 Subject: [PATCH 2/4] Second iteration, yet fully AI-coded --- cache/cache.go | 53 +++++ cache/group.go | 138 ++++++++++++ cache/user.go | 146 ++++++++++++ cernbox.go | 6 +- group/cache.go | 148 ------------ group/indigoiam/indigoiam.go | 331 --------------------------- group/indigoiam/rest.go | 392 ++++++++++++++++++++++++++++++++ group/rest/rest.go | 25 ++- user/cache.go | 162 -------------- user/indigoiam/indigoiam.go | 352 ----------------------------- user/indigoiam/rest.go | 421 +++++++++++++++++++++++++++++++++++ user/rest/rest.go | 37 ++- 12 files changed, 1195 insertions(+), 1016 deletions(-) create mode 100644 cache/cache.go create mode 100644 cache/group.go create mode 100644 cache/user.go delete mode 100644 group/cache.go delete mode 100644 group/indigoiam/indigoiam.go create mode 100644 group/indigoiam/rest.go delete mode 100644 user/cache.go delete mode 100644 user/indigoiam/indigoiam.go create mode 100644 user/indigoiam/rest.go 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..0a1e082 --- /dev/null +++ b/cache/user.go @@ -0,0 +1,146 @@ +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:" +) + +// 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 +} 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/indigoiam.go b/group/indigoiam/indigoiam.go deleted file mode 100644 index 36d6433..0000000 --- a/group/indigoiam/indigoiam.go +++ /dev/null @@ -1,331 +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. - -// A Group provider following the Indigo IAM REST API -// See https://indigo-iam.github.io/v/v1.14.0/docs/reference/api/account-api/ - -package indigoiam - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - 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/group" - "github.com/go-redis/redis/v8" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" -) - -func init() { - reva.RegisterPlugin(manager{}) -} - -type manager struct { - conf *config - client *http.Client - redisClient *redis.Client -} - -func (manager) RevaPlugin() reva.PluginInfo { - return reva.PluginInfo{ - ID: "grpc.services.groupprovider.drivers.indigoiam", - New: New, - } -} - -// config maps the configuration fields for the Indigo IAM group provider -type config struct { - Endpoint string `mapstructure:"endpoint"` - ClientToken string `mapstructure:"client_token"` // Admin token with `iam:admin.read` scope - Idp string `mapstructure:"idp"` - RedisAddress string `mapstructure:"redis_address"` - RedisPassword string `mapstructure:"redis_password"` - RedisDB int `mapstructure:"redis_db"` - CacheTTL time.Duration `mapstructure:"cache_ttl"` -} - -// IAMGroup represents the JSON structure from the Indigo IAM /iam/group/search API -type IAMGroup struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// IAMMember represents a simplified account identity when resolving memberships -type IAMMember struct { - ID string `json:"id"` - Username string `json:"username"` -} - -// New returns a new group.Manager interacting with Indigo IAM -func New(ctx context.Context, m map[string]any) (group.Manager, error) { - c := &config{} - if err := mapstructure.Decode(m, c); err != nil { - return nil, errors.Wrap(err, "error decoding configuration") - } - - if c.Endpoint == "" { - return nil, errors.New("indigo iam group provider: missing endpoint configuration") - } - if c.ClientToken == "" { - return nil, errors.New("indigo iam group provider: missing client token configuration") - } - if c.CacheTTL == 0 { - c.CacheTTL = 5 * time.Minute - } - - var rClient *redis.Client - if c.RedisAddress != "" { - rClient = redis.NewClient(&redis.Options{ - Addr: c.RedisAddress, - Password: c.RedisPassword, - DB: c.RedisDB, - }) - } - - return &manager{ - conf: c, - client: &http.Client{Timeout: 10 * time.Second}, - redisClient: rClient, - }, nil -} - -func (m *manager) Configure(ml map[string]interface{}) error { - return nil -} - -// GetGroup fetches group details by its unique identifier -func (m *manager) GetGroup(ctx context.Context, gid *grouppb.GroupId) (*grouppb.Group, error) { - if gid == nil || gid.OpaqueId == "" { - return nil, errors.New("indigo iam group provider: empty group id") - } - - cacheKey := "group:id:" + gid.OpaqueId - if m.redisClient != nil { - if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { - var g grouppb.Group - if err := json.Unmarshal([]byte(val), &g); err == nil { - return &g, nil - } - } - } - - // Fetch groups and isolate the one matching target identifier - url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) - var groups []IAMGroup - if err := m.doRequest(ctx, url, &groups); err != nil { - return nil, err - } - - for _, g := range groups { - if g.ID == gid.OpaqueId { - targetGroup := m.iamGroupToGroup(&g) - m.storeInCache(ctx, cacheKey, targetGroup) - return targetGroup, nil - } - } - - return nil, errors.New("group not found") -} - -// GetGroupByClaim performs unique lookups using attributes like the group's name -func (m *manager) GetGroupByClaim(ctx context.Context, claim, value string) (*grouppb.Group, error) { - if claim == "" || value == "" { - return nil, errors.New("indigo iam group provider: empty claim or value") - } - - if claim != "name" && claim != "groupname" { - return nil, fmt.Errorf("indigo iam group provider: unsupported claim: %s", claim) - } - - cacheKey := "group:name:" + value - if m.redisClient != nil { - if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { - var g grouppb.Group - if err := json.Unmarshal([]byte(val), &g); err == nil { - return &g, nil - } - } - } - - url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) - var groups []IAMGroup - if err := m.doRequest(ctx, url, &groups); err != nil { - return nil, err - } - - for _, g := range groups { - if g.Name == value { - targetGroup := m.iamGroupToGroup(&g) - m.storeInCache(ctx, cacheKey, targetGroup) - return targetGroup, nil - } - } - - return nil, fmt.Errorf("group not found with claim %s: %s", claim, value) -} - -// FindGroups searches for lists of groups matching a query string fraction -func (m *manager) FindGroups(ctx context.Context, query string) ([]*grouppb.Group, error) { - url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint) - var iamGroups []IAMGroup - if err := m.doRequest(ctx, url, &iamGroups); err != nil { - return nil, err - } - - var groups []*grouppb.Group - for _, ig := range iamGroups { - // Basic case-insensitive substring matching if query is provided - if query == "" || legacyContains(ig.Name, query) { - groups = append(groups, m.iamGroupToGroup(&ig)) - } - } - - return groups, nil -} - -// GetMembers evaluates and lists members assigned to the requested group ID -func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) { - if gid == nil || gid.OpaqueId == "" { - return nil, errors.New("indigo iam group provider: empty group id") - } - - cacheKey := "group:members:" + gid.OpaqueId - if m.redisClient != nil { - if cached, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { - var ids []*userpb.UserId - if err := json.Unmarshal([]byte(cached), &ids); err == nil { - return ids, nil - } - } - } - - // Resolve target group identity first to obtain its explicit human name - g, err := m.GetGroup(ctx, gid) - if err != nil { - return nil, err - } - - // Leverage Account query searching filtering matching users linked directly to the group name - url := fmt.Sprintf("%s/iam/account/search?filter=group:%s", m.conf.Endpoint, g.GroupName) - var members []IAMMember - if err := m.doRequest(ctx, url, &members); err != nil { - return nil, err - } - - userIds := make([]*userpb.UserId, len(members)) - for i, mber := range members { - userIds[i] = &userpb.UserId{ - OpaqueId: mber.ID, - Idp: m.conf.Idp, - Type: userpb.UserType_USER_TYPE_PRIMARY, - } - } - - if m.redisClient != nil && len(userIds) > 0 { - if data, err := json.Marshal(userIds); err == nil { - _ = m.redisClient.Set(ctx, cacheKey, data, m.conf.CacheTTL).Err() - } - } - - return userIds, nil -} - -// HasMember checks if a specific user identifier belongs within a target group context -func (m *manager) HasMember(ctx context.Context, gid *grouppb.GroupId, uid *userpb.UserId) (bool, error) { - if gid == nil || uid == nil { - return false, errors.New("indigo iam group provider: invalid group or user identity") - } - - members, err := m.GetMembers(ctx, gid) - if err != nil { - return false, err - } - - for _, member := range members { - if member.OpaqueId == uid.OpaqueId { - return true, nil - } - } - - return false, nil -} - -// Internal implementation helpers - -func (m *manager) doRequest(ctx context.Context, url string, target interface{}) error { - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return errors.Wrap(err, "failed to build request context") - } - - req.Header.Set("Authorization", "Bearer "+m.conf.ClientToken) - req.Header.Set("Accept", "application/json") - - resp, err := m.client.Do(req) - if err != nil { - return errors.Wrap(err, "http execution failed against endpoint") - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return errors.New("group entity resource not found") - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected upstream api reply status: %d", resp.StatusCode) - } - - return json.NewDecoder(resp.Body).Decode(target) -} - -func (m *manager) iamGroupToGroup(g *IAMGroup) *grouppb.Group { - return &grouppb.Group{ - Id: &grouppb.GroupId{ - OpaqueId: g.ID, - Idp: m.conf.Idp, - }, - GroupName: g.Name, - DisplayName: g.Name, - } -} - -func (m *manager) storeInCache(ctx context.Context, directKey string, g *grouppb.Group) { - if m.redisClient == nil { - return - } - data, err := json.Marshal(g) - if err != nil { - return - } - - pipe := m.redisClient.Pipeline() - pipe.Set(ctx, directKey, data, m.conf.CacheTTL) - pipe.Set(ctx, "group:id:"+g.Id.OpaqueId, data, m.conf.CacheTTL) - pipe.Set(ctx, "group:name:"+g.GroupName, data, m.conf.CacheTTL) - _, _ = pipe.Exec(ctx) -} - -func legacyContains(s, substr string) bool { - // Simple matching layer mirroring standard lower case lookups - return len(s) >= len(substr) && (s == substr || s[:len(substr)] == substr) -} diff --git a/group/indigoiam/rest.go b/group/indigoiam/rest.go new file mode 100644 index 0000000..9c39e8d --- /dev/null +++ b/group/indigoiam/rest.go @@ -0,0 +1,392 @@ +// 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 + 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), + 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 + } + members = append(members, &userpb.UserId{ + OpaqueId: acc.ID, + Idp: m.conf.IDProvider, + Type: userpb.UserType_USER_TYPE_PRIMARY, + }) + } + + 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/rest.go b/group/rest/rest.go index aeecdd6..198d599 100644 --- a/group/rest/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/indigoiam.go b/user/indigoiam/indigoiam.go deleted file mode 100644 index 87dd17e..0000000 --- a/user/indigoiam/indigoiam.go +++ /dev/null @@ -1,352 +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. - -// A user provider following the Indigo IAM REST API -// See https://indigo-iam.github.io/v/v1.14.0/docs/reference/api/account-api/ - -package indigoiam - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - "github.com/cs3org/reva/v3" - "github.com/cs3org/reva/v3/pkg/user" - "github.com/go-redis/redis/v8" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" -) - -func init() { - reva.RegisterPlugin(manager{}) -} - -type manager struct { - conf *config - client *http.Client - redisClient *redis.Client -} - -func (manager) RevaPlugin() reva.PluginInfo { - return reva.PluginInfo{ - ID: "grpc.services.userprovider.drivers.indigoiam", - New: New, - } -} - -// config maps the configuration fields for the Indigo IAM user provider -type config struct { - Endpoint string `mapstructure:"endpoint"` - ClientToken string `mapstructure:"client_token";doc:"Admin token with iam:admin.read scope"` - Idp string `mapstructure:"idp"` - PrimaryUsers []string `mapstructure:"primary_users";default:"[]";doc:"A list of user IDs that should be treated as primary users. If empty, everyone is considered primary."` - RedisAddress string `mapstructure:"redis_address"` - RedisPassword string `mapstructure:"redis_password"` - RedisDB int `mapstructure:"redis_db"` - CacheTTL time.Duration `mapstructure:"cache_ttl"` -} - -// IAMAccount represents the JSON payload structure from the Indigo IAM account/search API -type IAMAccount struct { - ID string `json:"id"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` -} - -// IAMGroup represents the group payload from /iam/account/{id}/groups -type IAMGroup struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// New returns a new user.Manager interacting with Indigo IAM -func New(ctx context.Context, m map[string]any) (user.Manager, error) { - c := &config{} - if err := mapstructure.Decode(m, c); err != nil { - return nil, errors.Wrap(err, "error decoding configuration") - } - - if c.Endpoint == "" { - return nil, errors.New("indigo iam provider: missing endpoint configuration") - } - if c.ClientToken == "" { - return nil, errors.New("indigo iam provider: missing client token configuration") - } - if c.CacheTTL == 0 { - c.CacheTTL = 5 * time.Minute - } - - var rClient *redis.Client - if c.RedisAddress != "" { - rClient = redis.NewClient(&redis.Options{ - Addr: c.RedisAddress, - Password: c.RedisPassword, - DB: c.RedisDB, - }) - } - - return &manager{ - conf: c, - client: &http.Client{Timeout: 10 * time.Second}, - redisClient: rClient, - }, nil -} - -func (m *manager) Configure(ml map[string]interface{}) error { - return nil -} - -// GetUser fetches user information by unique account identifier -func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId, skipFetchingGroups bool) (*userpb.User, error) { - if uid == nil || uid.OpaqueId == "" { - return nil, errors.New("indigo iam provider: empty user id") - } - - cacheKey := "user:id:" + uid.OpaqueId - if m.redisClient != nil { - if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { - var u userpb.User - if err := json.Unmarshal([]byte(val), &u); err == nil { - if !skipFetchingGroups && len(u.Groups) == 0 { - groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) - if err == nil { - u.Groups = groups - } - } - return &u, nil - } - } - } - - // Leverage /iam/account/search endpoint targeting the primary unique ID - url := fmt.Sprintf("%s/iam/account/search?Id=%s", m.conf.Endpoint, uid.OpaqueId) - var accounts []IAMAccount - if err := m.doRequest(ctx, url, &accounts); err != nil { - return nil, err - } - - if len(accounts) == 0 { - return nil, errors.New("user not found") - } - - u := m.iamAccountToUser(&accounts[0]) - - if !skipFetchingGroups { - groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) - if err == nil { - u.Groups = groups - } - } - - m.storeInCache(ctx, cacheKey, u) - return u, nil -} - -// GetUserByClaim looks up a user matching properties like username or mail fields -func (m *manager) GetUserByClaim(ctx context.Context, claim, value, tenantID string, skipFetchingGroups bool) (*userpb.User, error) { - if claim == "" || value == "" { - return nil, errors.New("indigo iam provider: empty claim or value") - } - - var searchParam string - switch claim { - case "username": - searchParam = "username" - case "mail", "email": - searchParam = "email" - default: - return nil, fmt.Errorf("indigo iam provider: unsupported lookup claim: %s", claim) - } - - cacheKey := fmt.Sprintf("user:%s:%s", searchParam, value) - if m.redisClient != nil { - if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil { - var u userpb.User - if err := json.Unmarshal([]byte(val), &u); err == nil { - if !skipFetchingGroups && len(u.Groups) == 0 { - groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) - if err == nil { - u.Groups = groups - } - } - return &u, nil - } - } - } - - url := fmt.Sprintf("%s/iam/account/search?%s=%s", m.conf.Endpoint, searchParam, value) - var accounts []IAMAccount - if err := m.doRequest(ctx, url, &accounts); err != nil { - return nil, err - } - - if len(accounts) == 0 { - return nil, fmt.Errorf("user not found with %s: %s", claim, value) - } - - u := m.iamAccountToUser(&accounts[0]) - - if !skipFetchingGroups { - groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) - if err == nil { - u.Groups = groups - } - } - - m.storeInCache(ctx, cacheKey, u) - return u, nil -} - -// FindUsers searches for subsets of accounts matching names, mail handles or usernames -func (m *manager) FindUsers(ctx context.Context, query, tenantID string, skipFetchingGroups bool) ([]*userpb.User, error) { - // Attempt matching on full/display names first - url := fmt.Sprintf("%s/iam/account/search?name=%s", m.conf.Endpoint, query) - var accounts []IAMAccount - if err := m.doRequest(ctx, url, &accounts); err != nil { - return nil, err - } - - // Fallback lookup strategy by username if generic query yielded nothing - if len(accounts) == 0 { - url = fmt.Sprintf("%s/iam/account/search?username=%s", m.conf.Endpoint, query) - if err := m.doRequest(ctx, url, &accounts); err != nil { - return nil, err - } - } - - users := make([]*userpb.User, 0, len(accounts)) - for _, acc := range accounts { - u := m.iamAccountToUser(&acc) - if !skipFetchingGroups { - groups, err := m.fetchUserGroups(ctx, u.Id.OpaqueId) - if err == nil { - u.Groups = groups - } - } - users = append(users, u) - } - - return users, nil -} - -// GetUserGroups resolves user identities directly to assigned string groups -func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) { - if uid == nil || uid.OpaqueId == "" { - return nil, errors.New("indigo iam provider: empty user id") - } - return m.fetchUserGroups(ctx, uid.OpaqueId) -} - -// Internal implementation helpers - -func (m *manager) doRequest(ctx context.Context, url string, target interface{}) error { - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return errors.Wrap(err, "failed to build request context") - } - - req.Header.Set("Authorization", "Bearer "+m.conf.ClientToken) - req.Header.Set("Accept", "application/json") - - resp, err := m.client.Do(req) - if err != nil { - return errors.Wrap(err, "http execution failed against endpoint") - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return errors.New("identity entity not found mapping resource") - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected upstream api reply status: %d", resp.StatusCode) - } - - return json.NewDecoder(resp.Body).Decode(target) -} - -func (m *manager) fetchUserGroups(ctx context.Context, userID string) ([]string, error) { - cacheKey := "user:groups:" + userID - if m.redisClient != nil { - if groups, err := m.redisClient.SMembers(ctx, cacheKey).Result(); err == nil && len(groups) > 0 { - return groups, nil - } - } - - url := fmt.Sprintf("%s/iam/account/%s/groups", m.conf.Endpoint, userID) - var iamGroups []IAMGroup - if err := m.doRequest(ctx, url, &iamGroups); err != nil { - return nil, err - } - - groups := make([]string, len(iamGroups)) - for i, g := range iamGroups { - groups[i] = g.Name - } - - if m.redisClient != nil && len(groups) > 0 { - pipe := m.redisClient.Pipeline() - pipe.SAdd(ctx, cacheKey, groups) - pipe.Expire(ctx, cacheKey, m.conf.CacheTTL) - _, _ = pipe.Exec(ctx) - } - - return groups, nil -} - -func (m *manager) iamAccountToUser(account *IAMAccount) *userpb.User { - var ut userpb.UserType - if m.conf.PrimaryUsers == nil || stringInSlice(account.ID, m.conf.PrimaryUsers) { - ut = userpb.UserType_USER_TYPE_PRIMARY - } else { - ut = userpb.UserType_USER_TYPE_LIGHTWEIGHT - } - return &userpb.User{ - Id: &userpb.UserId{ - OpaqueId: account.ID, - Idp: m.conf.Idp, - Type: ut, - }, - Username: account.Username, - Mail: account.Email, - DisplayName: account.Name, - } -} - -func (m *manager) storeInCache(ctx context.Context, directKey string, u *userpb.User) { - if m.redisClient == nil { - return - } - data, err := json.Marshal(u) - if err != nil { - return - } - - pipe := m.redisClient.Pipeline() - pipe.Set(ctx, directKey, data, m.conf.CacheTTL) - pipe.Set(ctx, "user:id:"+u.Id.OpaqueId, data, m.conf.CacheTTL) - pipe.Set(ctx, "user:username:"+u.Username, data, m.conf.CacheTTL) - if u.Mail != "" { - pipe.Set(ctx, "user:email:"+u.Mail, data, m.conf.CacheTTL) - } - _, _ = pipe.Exec(ctx) -} diff --git a/user/indigoiam/rest.go b/user/indigoiam/rest.go new file mode 100644 index 0000000..2fe406d --- /dev/null +++ b/user/indigoiam/rest.go @@ -0,0 +1,421 @@ +// 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 +} + +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"` +} + +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 := m.accountToProto(acc) + if err := m.cache.StoreUser(u); err != nil { + log.Error().Err(err).Str("uuid", acc.ID).Msg("indigoiam user: cache error") + } + } + + nextStart := startIndex + list.ItemsPerPage + if nextStart > list.TotalResults { + break + } + startIndex = nextStart + } + return nil +} + +func (m *manager) accountToProto(acc *iamAccount) *userpb.User { + return &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: acc.ID, + Idp: m.conf.IDProvider, + Type: userpb.UserType_USER_TYPE_PRIMARY, + }, + Username: acc.UserName, + Mail: acc.primaryEmail(), + DisplayName: acc.DisplayName, + } +} + +// --------------------------------------------------------------------------- +// 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 + } + + url := fmt.Sprintf("%s/iam/account/%s/groups", m.conf.IAMBaseURL, uid.OpaqueId) + 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/rest.go b/user/rest/rest.go index 97cdb06..4269306 100644 --- a/user/rest/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") From c54bc923590803f1199b753cc109e25fe244fd9d Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Wed, 17 Jun 2026 08:59:11 +0200 Subject: [PATCH 3/4] Added primary_user config map and wiring --- cache/user.go | 51 +++++++++++++++++++++++++++++++---- group/indigoiam/rest.go | 21 +++++++++++++-- user/indigoiam/rest.go | 60 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/cache/user.go b/cache/user.go index 0a1e082..18c95ff 100644 --- a/cache/user.go +++ b/cache/user.go @@ -20,11 +20,13 @@ import ( // user:groups: → JSON []string const ( - userIDPrefix = "user:id:" - userUsernamePrefix = "user:username:" - userMailPrefix = "user:mail:" - userNamePrefix = "user:name:" - userGroupsPrefix = "user:groups:" + 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 @@ -144,3 +146,42 @@ func (c *UserCache) GetGroups(ctx context.Context, opaqueID string) ([]string, e } 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/group/indigoiam/rest.go b/group/indigoiam/rest.go index 9c39e8d..a4412a0 100644 --- a/group/indigoiam/rest.go +++ b/group/indigoiam/rest.go @@ -50,6 +50,7 @@ func init() { type manager struct { conf *config cache *cache.GroupCache + userCache *cache.UserCache // read-only access, for IAM-UUID -> OpaqueId resolution httpClient *http.Client } @@ -177,6 +178,13 @@ func New(ctx context.Context, m map[string]interface{}) (group.Manager, error) { 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, @@ -332,10 +340,19 @@ func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*user 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: acc.ID, + OpaqueId: opaqueID, Idp: m.conf.IDProvider, - Type: userpb.UserType_USER_TYPE_PRIMARY, + Type: userType, }) } diff --git a/user/indigoiam/rest.go b/user/indigoiam/rest.go index 2fe406d..c472871 100644 --- a/user/indigoiam/rest.go +++ b/user/indigoiam/rest.go @@ -51,6 +51,12 @@ type manager struct { 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", @@ -75,6 +81,14 @@ type config struct { 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() { @@ -261,10 +275,17 @@ func (m *manager) fetchAllUserAccounts(ctx context.Context) error { if !acc.Active { continue } - u := m.accountToProto(acc) + 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 @@ -276,17 +297,36 @@ func (m *manager) fetchAllUserAccounts(ctx context.Context) error { return nil } -func (m *manager) accountToProto(acc *iamAccount) *userpb.User { +// 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: acc.ID, + OpaqueId: opaqueID, Idp: m.conf.IDProvider, - Type: userpb.UserType_USER_TYPE_PRIMARY, + Type: userType, }, Username: acc.UserName, Mail: acc.primaryEmail(), DisplayName: acc.DisplayName, - } + UidNumber: uid, + GidNumber: gid, + }, remapped } // --------------------------------------------------------------------------- @@ -364,7 +404,15 @@ func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]stri return cached, nil } - url := fmt.Sprintf("%s/iam/account/%s/groups", m.conf.IAMBaseURL, uid.OpaqueId) + 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 From f4384411e2431e1d2ab089f456b099f5245d1048 Mon Sep 17 00:00:00 2001 From: Giuseppe Lo Presti Date: Fri, 3 Jul 2026 17:16:21 +0200 Subject: [PATCH 4/4] tokenmgr: added scope as config option --- utils/tokenmanagement.go | 2 ++ 1 file changed, 2 insertions(+) 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()))