Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69af4d19c8 | ||
|
|
92d8efea47 |
+39
-19
@@ -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 {
|
||||||
@@ -172,8 +177,10 @@ func (g AccessGate) normalized() *AccessGate {
|
|||||||
|
|
||||||
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
|
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
|
||||||
if acceptsHTML(r.Header.Get("Accept")) {
|
if acceptsHTML(r.Header.Get("Accept")) {
|
||||||
|
// 200 so AppAPI's proxy CSP keeps frame-ancestors 'self' and the ExApp
|
||||||
|
// iframe can show the message (403 responses get frame-ancestors 'none').
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(deniedHTML)
|
_, _ = w.Write(deniedHTML)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -185,32 +192,45 @@ 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>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="utf-8"><title>Access denied</title></head>
|
<head>
|
||||||
<body><h1>Access denied</h1><p>You are not a member of a required group for this app.</p></body>
|
<meta charset="utf-8">
|
||||||
|
<title>Access denied</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; }
|
||||||
|
body { margin: 2rem; max-width: 40rem; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
||||||
|
p { color: #444; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Access denied</h1>
|
||||||
|
<p>You are not a member of a required group for this app. Ask an administrator to add you to the group if you need access.</p>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`)
|
`)
|
||||||
|
|||||||
+30
-1
@@ -159,7 +159,7 @@ func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
|
|||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("got %d", rec.Code)
|
t.Fatalf("got %d", rec.Code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user