1 Commits
Author SHA1 Message Date
Konrad NeitzelandCursor 92d8efea47 Key Access Gate positive cache by user and Required Groups set.
Avoids reusing an allow decision after the configured group list changes on a live gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:14:07 +02:00
2 changed files with 51 additions and 16 deletions
+22 -16
View File
@@ -81,8 +81,9 @@ func (g *AccessGate) Check(r *http.Request) CheckResult {
} }
func (g *AccessGate) memberOfRequired(userID string) (bool, error) { func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
key := g.cacheKey(userID)
if g.CacheTTL > 0 { if g.CacheTTL > 0 {
if g.cachedAllowed(userID) { if g.cachedAllowed(key) {
return true, nil return true, nil
} }
} }
@@ -94,7 +95,7 @@ func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
for _, have := range groups { for _, have := range groups {
if have == need { if have == need {
if g.CacheTTL > 0 { if g.CacheTTL > 0 {
g.storeAllowed(userID) g.storeAllowed(key)
} }
return true, nil return true, nil
} }
@@ -103,6 +104,10 @@ func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
return false, nil return false, nil
} }
func (g *AccessGate) cacheKey(userID string) string {
return userID + "\x00" + strings.Join(g.Groups, "\x00")
}
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) { func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
ocs := g.ocsClient(userID) ocs := g.ocsClient(userID)
path := "cloud/users/" + url.PathEscape(userID) + "/groups" path := "cloud/users/" + url.PathEscape(userID) + "/groups"
@@ -139,24 +144,24 @@ func decodeUserGroups(raw []byte) ([]string, error) {
return parsed.OCS.Data.Groups, nil return parsed.OCS.Data.Groups, nil
} }
func (g *AccessGate) cachedAllowed(userID string) bool { func (g *AccessGate) cachedAllowed(key string) bool {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
ent, ok := g.cache[userID] ent, ok := g.cache[key]
if !ok { if !ok {
return false return false
} }
if g.Now().After(ent.until) { if g.Now().After(ent.until) {
delete(g.cache, userID) delete(g.cache, key)
return false return false
} }
return true return true
} }
func (g *AccessGate) storeAllowed(userID string) { func (g *AccessGate) storeAllowed(key string) {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
g.cache[userID] = cacheEntry{until: g.Now().Add(g.CacheTTL)} g.cache[key] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
} }
func (g AccessGate) normalized() *AccessGate { func (g AccessGate) normalized() *AccessGate {
@@ -185,27 +190,28 @@ func acceptsHTML(accept string) bool {
} }
func (g *AccessGate) shouldSkip(path string) bool { func (g *AccessGate) shouldSkip(path string) bool {
path = strings.TrimSuffix(path, "/") path = normalizeGatePath(path)
if path == "" {
path = "/"
}
for _, p := range defaultSkipPaths { for _, p := range defaultSkipPaths {
if path == p { if path == p {
return true return true
} }
} }
for _, p := range g.ExtraSkipPaths { for _, p := range g.ExtraSkipPaths {
p = strings.TrimSuffix(p, "/") if path == normalizeGatePath(p) {
if p == "" {
p = "/"
}
if path == p {
return true return true
} }
} }
return false return false
} }
func normalizeGatePath(path string) string {
path = strings.TrimSuffix(path, "/")
if path == "" {
return "/"
}
return path
}
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"} var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
var deniedHTML = []byte(`<!DOCTYPE html> var deniedHTML = []byte(`<!DOCTYPE html>
+29
View File
@@ -213,6 +213,35 @@ func TestAccessGateCachesPositiveMembership(t *testing.T) {
} }
} }
func TestAccessGateCacheKeyedByGroupSet(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
}
gate := &gonexapp.AccessGate{
Cred: cred, Groups: []string{"dns-ops"}, CacheTTL: time.Minute,
Client: srv.Client(), OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
}
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
if gate.Check(req) != gonexapp.CheckAllowed {
t.Fatal("first allow")
}
gate.Groups = []string{"other-group"}
if gate.Check(req) != gonexapp.CheckDenied {
t.Fatalf("after group-set change want denied, calls=%d", calls.Load())
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2 (cache must not reuse prior group-set)", calls.Load())
}
}
func TestAccessGateDoesNotCacheDenial(t *testing.T) { func TestAccessGateDoesNotCacheDenial(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) { srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {