Skip to content

Commit a718e1c

Browse files
committed
First edition of a user/group provider based on Indigo IAM
All AI-coded, to be tested
1 parent 59ba210 commit a718e1c

2 files changed

Lines changed: 682 additions & 0 deletions

File tree

group/indigoiam.go

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// Copyright 2018-2026 CERN
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// In applying this license, CERN does not waive the privileges and immunities
16+
// granted to it by virtue of its status as an Intergovernmental Organization
17+
// or submit itself to any jurisdiction.
18+
19+
// A Group provider following the Indigo IAM REST API
20+
// See https://indigo-iam.github.io/v/v1.14.0/docs/reference/api/account-api/
21+
22+
package indigoiam
23+
24+
import (
25+
"context"
26+
"encoding/json"
27+
"fmt"
28+
"net/http"
29+
"time"
30+
31+
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
32+
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
33+
"github.com/cs3org/reva/v3"
34+
"github.com/cs3org/reva/v3/pkg/group"
35+
"github.com/go-redis/redis/v8"
36+
"github.com/mitchellh/mapstructure"
37+
"github.com/pkg/errors"
38+
)
39+
40+
func init() {
41+
reva.RegisterPlugin(manager{})
42+
}
43+
44+
type manager struct {
45+
conf *config
46+
client *http.Client
47+
redisClient *redis.Client
48+
}
49+
50+
func (manager) RevaPlugin() reva.PluginInfo {
51+
return reva.PluginInfo{
52+
ID: "grpc.services.groupprovider.drivers.indigoiam",
53+
New: New,
54+
}
55+
}
56+
57+
// config maps the configuration fields for the Indigo IAM group provider
58+
type config struct {
59+
Endpoint string `mapstructure:"endpoint"`
60+
ClientToken string `mapstructure:"client_token"` // Admin token with `iam:admin.read` scope
61+
Idp string `mapstructure:"idp"`
62+
RedisAddress string `mapstructure:"redis_address"`
63+
RedisPassword string `mapstructure:"redis_password"`
64+
RedisDB int `mapstructure:"redis_db"`
65+
CacheTTL time.Duration `mapstructure:"cache_ttl"`
66+
}
67+
68+
// IAMGroup represents the JSON structure from the Indigo IAM /iam/group/search API
69+
type IAMGroup struct {
70+
ID string `json:"id"`
71+
Name string `json:"name"`
72+
}
73+
74+
// IAMMember represents a simplified account identity when resolving memberships
75+
type IAMMember struct {
76+
ID string `json:"id"`
77+
Username string `json:"username"`
78+
}
79+
80+
// New returns a new group.Manager interacting with Indigo IAM
81+
func New(ctx context.Context, m map[string]any) (group.Manager, error) {
82+
c := &config{}
83+
if err := mapstructure.Decode(m, c); err != nil {
84+
return nil, errors.Wrap(err, "error decoding configuration")
85+
}
86+
87+
if c.Endpoint == "" {
88+
return nil, errors.New("indigo iam group provider: missing endpoint configuration")
89+
}
90+
if c.ClientToken == "" {
91+
return nil, errors.New("indigo iam group provider: missing client token configuration")
92+
}
93+
if c.CacheTTL == 0 {
94+
c.CacheTTL = 5 * time.Minute
95+
}
96+
97+
var rClient *redis.Client
98+
if c.RedisAddress != "" {
99+
rClient = redis.NewClient(&redis.Options{
100+
Addr: c.RedisAddress,
101+
Password: c.RedisPassword,
102+
DB: c.RedisDB,
103+
})
104+
}
105+
106+
return &manager{
107+
conf: c,
108+
client: &http.Client{Timeout: 10 * time.Second},
109+
redisClient: rClient,
110+
}, nil
111+
}
112+
113+
func (m *manager) Configure(ml map[string]interface{}) error {
114+
return nil
115+
}
116+
117+
// GetGroup fetches group details by its unique identifier
118+
func (m *manager) GetGroup(ctx context.Context, gid *grouppb.GroupId) (*grouppb.Group, error) {
119+
if gid == nil || gid.OpaqueId == "" {
120+
return nil, errors.New("indigo iam group provider: empty group id")
121+
}
122+
123+
cacheKey := "group:id:" + gid.OpaqueId
124+
if m.redisClient != nil {
125+
if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil {
126+
var g grouppb.Group
127+
if err := json.Unmarshal([]byte(val), &g); err == nil {
128+
return &g, nil
129+
}
130+
}
131+
}
132+
133+
// Fetch groups and isolate the one matching target identifier
134+
url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint)
135+
var groups []IAMGroup
136+
if err := m.doRequest(ctx, url, &groups); err != nil {
137+
return nil, err
138+
}
139+
140+
for _, g := range groups {
141+
if g.ID == gid.OpaqueId {
142+
targetGroup := m.iamGroupToGroup(&g)
143+
m.storeInCache(ctx, cacheKey, targetGroup)
144+
return targetGroup, nil
145+
}
146+
}
147+
148+
return nil, errors.New("group not found")
149+
}
150+
151+
// GetGroupByClaim performs unique lookups using attributes like the group's name
152+
func (m *manager) GetGroupByClaim(ctx context.Context, claim, value string) (*grouppb.Group, error) {
153+
if claim == "" || value == "" {
154+
return nil, errors.New("indigo iam group provider: empty claim or value")
155+
}
156+
157+
if claim != "name" && claim != "groupname" {
158+
return nil, fmt.Errorf("indigo iam group provider: unsupported claim: %s", claim)
159+
}
160+
161+
cacheKey := "group:name:" + value
162+
if m.redisClient != nil {
163+
if val, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil {
164+
var g grouppb.Group
165+
if err := json.Unmarshal([]byte(val), &g); err == nil {
166+
return &g, nil
167+
}
168+
}
169+
}
170+
171+
url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint)
172+
var groups []IAMGroup
173+
if err := m.doRequest(ctx, url, &groups); err != nil {
174+
return nil, err
175+
}
176+
177+
for _, g := range groups {
178+
if g.Name == value {
179+
targetGroup := m.iamGroupToGroup(&g)
180+
m.storeInCache(ctx, cacheKey, targetGroup)
181+
return targetGroup, nil
182+
}
183+
}
184+
185+
return nil, fmt.Errorf("group not found with claim %s: %s", claim, value)
186+
}
187+
188+
// FindGroups searches for lists of groups matching a query string fraction
189+
func (m *manager) FindGroups(ctx context.Context, query string) ([]*grouppb.Group, error) {
190+
url := fmt.Sprintf("%s/iam/group/search", m.conf.Endpoint)
191+
var iamGroups []IAMGroup
192+
if err := m.doRequest(ctx, url, &iamGroups); err != nil {
193+
return nil, err
194+
}
195+
196+
var groups []*grouppb.Group
197+
for _, ig := range iamGroups {
198+
// Basic case-insensitive substring matching if query is provided
199+
if query == "" || legacyContains(ig.Name, query) {
200+
groups = append(groups, m.iamGroupToGroup(&ig))
201+
}
202+
}
203+
204+
return groups, nil
205+
}
206+
207+
// GetMembers evaluates and lists members assigned to the requested group ID
208+
func (m *manager) GetMembers(ctx context.Context, gid *grouppb.GroupId) ([]*userpb.UserId, error) {
209+
if gid == nil || gid.OpaqueId == "" {
210+
return nil, errors.New("indigo iam group provider: empty group id")
211+
}
212+
213+
cacheKey := "group:members:" + gid.OpaqueId
214+
if m.redisClient != nil {
215+
if cached, err := m.redisClient.Get(ctx, cacheKey).Result(); err == nil {
216+
var ids []*userpb.UserId
217+
if err := json.Unmarshal([]byte(cached), &ids); err == nil {
218+
return ids, nil
219+
}
220+
}
221+
}
222+
223+
// Resolve target group identity first to obtain its explicit human name
224+
g, err := m.GetGroup(ctx, gid)
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
// Leverage Account query searching filtering matching users linked directly to the group name
230+
url := fmt.Sprintf("%s/iam/account/search?filter=group:%s", m.conf.Endpoint, g.GroupName)
231+
var members []IAMMember
232+
if err := m.doRequest(ctx, url, &members); err != nil {
233+
return nil, err
234+
}
235+
236+
userIds := make([]*userpb.UserId, len(members))
237+
for i, mber := range members {
238+
userIds[i] = &userpb.UserId{
239+
OpaqueId: mber.ID,
240+
Idp: m.conf.Idp,
241+
Type: userpb.UserType_USER_TYPE_PRIMARY,
242+
}
243+
}
244+
245+
if m.redisClient != nil && len(userIds) > 0 {
246+
if data, err := json.Marshal(userIds); err == nil {
247+
_ = m.redisClient.Set(ctx, cacheKey, data, m.conf.CacheTTL).Err()
248+
}
249+
}
250+
251+
return userIds, nil
252+
}
253+
254+
// HasMember checks if a specific user identifier belongs within a target group context
255+
func (m *manager) HasMember(ctx context.Context, gid *grouppb.GroupId, uid *userpb.UserId) (bool, error) {
256+
if gid == nil || uid == nil {
257+
return false, errors.New("indigo iam group provider: invalid group or user identity")
258+
}
259+
260+
members, err := m.GetMembers(ctx, gid)
261+
if err != nil {
262+
return false, err
263+
}
264+
265+
for _, member := range members {
266+
if member.OpaqueId == uid.OpaqueId {
267+
return true, nil
268+
}
269+
}
270+
271+
return false, nil
272+
}
273+
274+
// Internal implementation helpers
275+
276+
func (m *manager) doRequest(ctx context.Context, url string, target interface{}) error {
277+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
278+
if err != nil {
279+
return errors.Wrap(err, "failed to build request context")
280+
}
281+
282+
req.Header.Set("Authorization", "Bearer "+m.conf.ClientToken)
283+
req.Header.Set("Accept", "application/json")
284+
285+
resp, err := m.client.Do(req)
286+
if err != nil {
287+
return errors.Wrap(err, "http execution failed against endpoint")
288+
}
289+
defer resp.Body.Close()
290+
291+
if resp.StatusCode == http.StatusNotFound {
292+
return errors.New("group entity resource not found")
293+
}
294+
if resp.StatusCode != http.StatusOK {
295+
return fmt.Errorf("unexpected upstream api reply status: %d", resp.StatusCode)
296+
}
297+
298+
return json.NewDecoder(resp.Body).Decode(target)
299+
}
300+
301+
func (m *manager) iamGroupToGroup(g *IAMGroup) *grouppb.Group {
302+
return &grouppb.Group{
303+
Id: &grouppb.GroupId{
304+
OpaqueId: g.ID,
305+
Idp: m.conf.Idp,
306+
},
307+
GroupName: g.Name,
308+
DisplayName: g.Name,
309+
}
310+
}
311+
312+
func (m *manager) storeInCache(ctx context.Context, directKey string, g *grouppb.Group) {
313+
if m.redisClient == nil {
314+
return
315+
}
316+
data, err := json.Marshal(g)
317+
if err != nil {
318+
return
319+
}
320+
321+
pipe := m.redisClient.Pipeline()
322+
pipe.Set(ctx, directKey, data, m.conf.CacheTTL)
323+
pipe.Set(ctx, "group:id:"+g.Id.OpaqueId, data, m.conf.CacheTTL)
324+
pipe.Set(ctx, "group:name:"+g.GroupName, data, m.conf.CacheTTL)
325+
_, _ = pipe.Exec(ctx)
326+
}
327+
328+
func legacyContains(s, substr string) bool {
329+
// Simple matching layer mirroring standard lower case lookups
330+
return len(s) >= len(substr) && (s == substr || s[:len(substr)] == substr)
331+
}

0 commit comments

Comments
 (0)