-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #441 from uselagoon/db-groups
Use Lagoon API DB to determine project group membership
- Loading branch information
Showing
16 changed files
with
596 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// Package cache implements a generic, thread-safe, in-memory cache. | ||
package cache | ||
|
||
import ( | ||
"sync" | ||
"time" | ||
) | ||
|
||
const ( | ||
defaultTTL = time.Minute | ||
) | ||
|
||
// Cache is a generic, thread-safe, in-memory cache that stores a value with a | ||
// TTL, after which the cache expires. | ||
type Cache[T any] struct { | ||
data T | ||
expiry time.Time | ||
ttl time.Duration | ||
mu sync.Mutex | ||
} | ||
|
||
// Option is a functional option argument to NewCache(). | ||
type Option[T any] func(*Cache[T]) | ||
|
||
// WithTTL sets the the Cache time-to-live to ttl. | ||
func WithTTL[T any](ttl time.Duration) Option[T] { | ||
return func(c *Cache[T]) { | ||
c.ttl = ttl | ||
} | ||
} | ||
|
||
// NewCache instantiates a Cache for type T with a default TTL of 1 minute. | ||
func NewCache[T any](options ...Option[T]) *Cache[T] { | ||
c := Cache[T]{ | ||
ttl: defaultTTL, | ||
} | ||
for _, option := range options { | ||
option(&c) | ||
} | ||
return &c | ||
} | ||
|
||
// Set updates the value in the cache and sets the expiry to now+TTL. | ||
func (c *Cache[T]) Set(value T) { | ||
c.mu.Lock() | ||
defer c.mu.Unlock() | ||
c.data = value | ||
c.expiry = time.Now().Add(c.ttl) | ||
} | ||
|
||
// Get retrieves the value from the cache. If cache has expired, the second | ||
// return value will be false. | ||
func (c *Cache[T]) Get() (T, bool) { | ||
c.mu.Lock() | ||
defer c.mu.Unlock() | ||
if time.Now().After(c.expiry) { | ||
var zero T | ||
return zero, false | ||
} | ||
return c.data, true | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
package cache_test | ||
|
||
import ( | ||
"testing" | ||
"time" | ||
|
||
"github.com/alecthomas/assert/v2" | ||
"github.com/uselagoon/ssh-portal/internal/cache" | ||
) | ||
|
||
func TestIntCache(t *testing.T) { | ||
var testCases = map[string]struct { | ||
input int | ||
expect int | ||
expired bool | ||
}{ | ||
"not expired": {input: 11, expect: 11}, | ||
"expired": {input: 11, expired: true}, | ||
} | ||
for name, tc := range testCases { | ||
t.Run(name, func(tt *testing.T) { | ||
c := cache.NewCache[int](cache.WithTTL[int](time.Second)) | ||
c.Set(tc.input) | ||
if tc.expired { | ||
time.Sleep(2 * time.Second) | ||
_, ok := c.Get() | ||
assert.False(tt, ok, name) | ||
} else { | ||
value, ok := c.Get() | ||
assert.True(tt, ok, name) | ||
assert.Equal(tt, tc.expect, value, name) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestMapCache(t *testing.T) { | ||
var testCases = map[string]struct { | ||
input map[string]string | ||
expect map[string]string | ||
expired bool | ||
}{ | ||
"expired": { | ||
input: map[string]string{"foo": "bar"}, | ||
expired: true, | ||
}, | ||
"not expired": { | ||
input: map[string]string{"foo": "bar"}, | ||
expect: map[string]string{"foo": "bar"}, | ||
}, | ||
} | ||
for name, tc := range testCases { | ||
t.Run(name, func(tt *testing.T) { | ||
c := cache.NewCache[map[string]string]( | ||
cache.WithTTL[map[string]string](time.Second), | ||
) | ||
c.Set(tc.input) | ||
if tc.expired { | ||
time.Sleep(2 * time.Second) | ||
_, ok := c.Get() | ||
assert.False(tt, ok, name) | ||
} else { | ||
value, ok := c.Get() | ||
assert.True(tt, ok, name) | ||
assert.Equal(tt, tc.expect, value, name) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package keycloak | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"path" | ||
|
||
"golang.org/x/oauth2/clientcredentials" | ||
) | ||
|
||
// Group represents a Keycloak Group. It holds the fields required when getting | ||
// a list of groups from keycloak. | ||
type Group struct { | ||
ID string `json:"id"` | ||
Name string `json:"name"` | ||
} | ||
|
||
func (c *Client) httpClient(ctx context.Context) *http.Client { | ||
cc := clientcredentials.Config{ | ||
ClientID: c.clientID, | ||
ClientSecret: c.clientSecret, | ||
TokenURL: c.oidcConfig.TokenEndpoint, | ||
} | ||
return cc.Client(ctx) | ||
} | ||
|
||
// rawGroups returns the raw JSON group representation from the Keycloak API. | ||
func (c *Client) rawGroups(ctx context.Context) ([]byte, error) { | ||
groupsURL := *c.baseURL | ||
groupsURL.Path = path.Join(c.baseURL.Path, | ||
"/auth/admin/realms/lagoon/groups") | ||
req, err := http.NewRequestWithContext(ctx, "GET", groupsURL.String(), nil) | ||
if err != nil { | ||
return nil, fmt.Errorf("couldn't construct groups request: %v", err) | ||
} | ||
q := req.URL.Query() | ||
q.Add("briefRepresentation", "true") | ||
req.URL.RawQuery = q.Encode() | ||
res, err := c.httpClient(ctx).Do(req) | ||
if err != nil { | ||
return nil, fmt.Errorf("couldn't get groups: %v", err) | ||
} | ||
defer res.Body.Close() | ||
if res.StatusCode > 299 { | ||
body, _ := io.ReadAll(res.Body) | ||
return nil, fmt.Errorf("bad groups response: %d\n%s", res.StatusCode, body) | ||
} | ||
return io.ReadAll(res.Body) | ||
} | ||
|
||
// GroupNameGroupIDMap returns a map of Keycloak Group names to Group IDs. | ||
func (c *Client) GroupNameGroupIDMap( | ||
ctx context.Context, | ||
) (map[string]string, error) { | ||
// rate limit keycloak API access | ||
if err := c.limiter.Wait(ctx); err != nil { | ||
return nil, fmt.Errorf("couldn't wait for limiter: %v", err) | ||
} | ||
// prefer to use cached value | ||
if groupNameGroupIDMap, ok := c.groupCache.Get(); ok { | ||
return groupNameGroupIDMap, nil | ||
} | ||
// otherwise get data from keycloak | ||
data, err := c.rawGroups(ctx) | ||
if err != nil { | ||
return nil, fmt.Errorf("couldn't get groups from Keycloak API: %v", err) | ||
} | ||
var groups []Group | ||
if err := json.Unmarshal(data, &groups); err != nil { | ||
return nil, fmt.Errorf("couldn't unmarshal Keycloak groups: %v", err) | ||
} | ||
groupNameGroupIDMap := map[string]string{} | ||
for _, group := range groups { | ||
groupNameGroupIDMap[group.Name] = group.ID | ||
} | ||
// update cache | ||
c.groupCache.Set(groupNameGroupIDMap) | ||
return groupNameGroupIDMap, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.