Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions cache/cache.go
Original file line number Diff line number Diff line change
@@ -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 <pattern> 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
}
138 changes: 138 additions & 0 deletions cache/group.go
Original file line number Diff line number Diff line change
@@ -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:<opaqueID> → JSON grouppb.Group
// group:name:<opaqueID>_<display_lower_snake> → bare UUID string
// group:members:<opaqueID> → 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
}
187 changes: 187 additions & 0 deletions cache/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cache

import (
"context"
"encoding/json"
"fmt"
"strings"

redispools "github.com/cernbox/reva-plugins/redispools"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/cs3org/reva/v3/pkg/appctx"
"github.com/cs3org/reva/v3/pkg/errtypes"
)

// Key schema:
// user:id:<opaqueID> → JSON userpb.User
// user:username:<login> → JSON userpb.User
// user:mail:<email> → JSON userpb.User
// user:name:<opaqueID>_<display_lower_snake> → JSON userpb.User
// user:groups:<opaqueID> → JSON []string

const (
userIDPrefix = "user:id:"
userUsernamePrefix = "user:username:"
userMailPrefix = "user:mail:"
userNamePrefix = "user:name:"
userGroupsPrefix = "user:groups:"
userIAMUUIDPrefix = "user:iamuuid:" // opaqueID -> IAM account UUID
userOpaqueIDByIAMUUID = "user:byiamuuid:" // IAM account UUID -> opaqueID
)

// UserCache is a Redis-backed cache for CS3 user objects and their group
// membership lists.
type UserCache struct {
pools *redispools.RedisPools
userTTLSecs int // 5 × fetch_interval
groupsTTLSecs int // group membership TTL
}

// NewUserCache creates a UserCache.
//
// fetchInterval – seconds between full IAM/GRAPPA syncs (used to
// derive user record TTL as 5× this value)
// groupsCacheMinutes – TTL for per-user group membership lists
func NewUserCache(pools *redispools.RedisPools, fetchInterval, groupsCacheMinutes int) *UserCache {
return &UserCache{
pools: pools,
userTTLSecs: 5 * fetchInterval,
groupsTTLSecs: groupsCacheMinutes * 60,
}
}

// StoreUser writes a user under all applicable index keys.
func (c *UserCache) StoreUser(u *userpb.User) error {
if err := store(c.pools, userIDPrefix+strings.ToLower(u.Id.OpaqueId), u, c.userTTLSecs); err != nil {
return err
}
if u.Username != "" {
if err := store(c.pools, userUsernamePrefix+strings.ToLower(u.Username), u, c.userTTLSecs); err != nil {
return err
}
}
if u.Mail != "" {
if err := store(c.pools, userMailPrefix+strings.ToLower(u.Mail), u, c.userTTLSecs); err != nil {
return err
}
}
if u.DisplayName != "" {
nameKey := userNamePrefix +
strings.ToLower(u.Id.OpaqueId) + "_" +
strings.ReplaceAll(strings.ToLower(u.DisplayName), " ", "_")
if err := store(c.pools, nameKey, u, c.userTTLSecs); err != nil {
return err
}
}
return nil
}

// GetByID looks up a user by their OpaqueId.
func (c *UserCache) GetByID(ctx context.Context, opaqueID string) (*userpb.User, error) {
var u userpb.User
if err := fetch(ctx, c.pools, userIDPrefix+strings.ToLower(opaqueID), &u); err != nil {
return nil, errtypes.NotFound(opaqueID)
}
return &u, nil
}

// GetByUsername looks up a user by their login name.
func (c *UserCache) GetByUsername(ctx context.Context, username string) (*userpb.User, error) {
var u userpb.User
if err := fetch(ctx, c.pools, userUsernamePrefix+strings.ToLower(username), &u); err != nil {
return nil, errtypes.NotFound(username)
}
return &u, nil
}

// GetByMail looks up a user by their primary email address.
func (c *UserCache) GetByMail(ctx context.Context, mail string) (*userpb.User, error) {
var u userpb.User
if err := fetch(ctx, c.pools, userMailPrefix+strings.ToLower(mail), &u); err != nil {
return nil, errtypes.NotFound(mail)
}
return &u, nil
}

// Find scans all user index keys matching query and returns the deduplicated
// set of users. An empty query returns all cached users.
func (c *UserCache) Find(ctx context.Context, query string) ([]*userpb.User, error) {
pattern := fmt.Sprintf(
"user:*%s*",
strings.ReplaceAll(strings.ToLower(query), " ", "_"),
)
values, err := scanAndFetch(ctx, c.pools, pattern)
if err != nil {
return nil, err
}

seen := make(map[string]*userpb.User)
for _, s := range values {
var u userpb.User
// Only keep users that have no "inactive" status set (cf. parseAndCacheUser)
if err := json.Unmarshal([]byte(s), &u); err == nil && u.Id != nil && u.Id.OpaqueId != "" && u.Status != userpb.UserStatus_USER_STATUS_EXPIRING {
seen[u.Id.OpaqueId] = &u
}
}

result := make([]*userpb.User, 0, len(seen))
for _, u := range seen {
result = append(result, u)
}
appctx.GetLogger(ctx).Debug().Str("pattern", pattern).Int("results", len(result)).Msg("cache: Find users")
return result, nil
}

// StoreGroups writes the group membership list for a user.
func (c *UserCache) StoreGroups(uid *userpb.UserId, groups []string) error {
return store(c.pools, userGroupsPrefix+strings.ToLower(uid.OpaqueId), groups, c.groupsTTLSecs)
}

// GetGroups returns the cached group membership list for a user, or an error
// if the entry is absent (so the caller can fall back to a live API fetch).
func (c *UserCache) GetGroups(ctx context.Context, opaqueID string) ([]string, error) {
var groups []string
if err := fetch(ctx, c.pools, userGroupsPrefix+strings.ToLower(opaqueID), &groups); err != nil {
return nil, err
}
return groups, nil
}

// StoreIAMUUID records the two-way mapping between a remapped OpaqueId and
// the original IAM account UUID, so callers can resolve in either direction:
// - GetIAMUUID: opaqueID -> iamUUID (used by the user driver for
// group-membership lookups against the IAM API)
// - GetOpaqueIDByIAMUUID: iamUUID -> opaqueID (used by the group driver
// to report the same OpaqueId for a member that GetUser/FindUsers
// would report)
//
// Only called for accounts that were actually remapped via primary_users;
// the common case (OpaqueId == IAM UUID) needs no entry in either direction.
func (c *UserCache) StoreIAMUUID(opaqueID, iamUUID string) error {
if err := store(c.pools, userIAMUUIDPrefix+strings.ToLower(opaqueID), iamUUID, c.userTTLSecs); err != nil {
return err
}
return store(c.pools, userOpaqueIDByIAMUUID+strings.ToLower(iamUUID), opaqueID, c.userTTLSecs)
}

// GetIAMUUID resolves the IAM account UUID for a given OpaqueId. If no
// mapping is present, callers should fall back to treating opaqueID itself
// as the IAM UUID.
func (c *UserCache) GetIAMUUID(ctx context.Context, opaqueID string) (string, error) {
var uuid string
if err := fetch(ctx, c.pools, userIAMUUIDPrefix+strings.ToLower(opaqueID), &uuid); err != nil {
return "", err
}
return uuid, nil
}

// GetOpaqueIDByIAMUUID resolves the public OpaqueId for a given IAM account
// UUID. If no mapping is present, callers should fall back to treating the
// IAM UUID itself as the OpaqueId (the common, non-remapped case).
func (c *UserCache) GetOpaqueIDByIAMUUID(ctx context.Context, iamUUID string) (string, error) {
var opaqueID string
if err := fetch(ctx, c.pools, userOpaqueIDByIAMUUID+strings.ToLower(iamUUID), &opaqueID); err != nil {
return "", err
}
return opaqueID, nil
}
6 changes: 4 additions & 2 deletions cernbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Loading
Loading