Add Access Gate for Required Groups on ExApp HTTP traffic.

AppAPI cannot restrict ExApps by Nextcloud group; enforce any-of membership in-library with Wrap/Check, deploy-env helpers, and positive-only cache.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Konrad Neitzel
2026-08-27 17:10:58 +02:00
co-authored by Cursor
parent 7921694782
commit 3980263146
6 changed files with 651 additions and 8 deletions
+216
View File
@@ -0,0 +1,216 @@
package gonexapp
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// AccessGate enforces Required Groups for the Requesting user on ExApp HTTP traffic.
type AccessGate struct {
Cred Credentials
Groups []string
CacheTTL time.Duration // 0 disables cache
ExtraSkipPaths []string
Client *http.Client
OCS OCSClient
Now func() time.Time
mu sync.Mutex
cache map[string]cacheEntry
}
type cacheEntry struct {
until time.Time
}
// CheckResult is the outcome of AccessGate.Check.
type CheckResult int
const (
CheckAllowed CheckResult = iota
CheckDenied
CheckUnauthorized
CheckUnavailable
)
// Wrap returns a handler that applies the Access Gate before next.
func (g AccessGate) Wrap(next http.Handler) http.Handler {
gate := g.normalized()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch gate.Check(r) {
case CheckAllowed:
next.ServeHTTP(w, r)
case CheckUnauthorized:
http.Error(w, "unauthorized", http.StatusUnauthorized)
case CheckUnavailable:
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
default:
gate.writeDenied(w, r)
}
})
}
// Check reports whether r may proceed under Required Groups.
func (g *AccessGate) Check(r *http.Request) CheckResult {
if g.cache == nil {
g.cache = make(map[string]cacheEntry)
}
if g.Now == nil {
g.Now = time.Now
}
if g.shouldSkip(r.URL.Path) || len(g.Groups) == 0 {
return CheckAllowed
}
user, err := UserFromRequest(r)
if err != nil || user == "" {
return CheckUnauthorized
}
ok, err := g.memberOfRequired(user)
if err != nil {
return CheckUnavailable
}
if ok {
return CheckAllowed
}
return CheckDenied
}
func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
if g.CacheTTL > 0 {
if g.cachedAllowed(userID) {
return true, nil
}
}
groups, err := g.fetchUserGroups(userID)
if err != nil {
return false, err
}
for _, need := range g.Groups {
for _, have := range groups {
if have == need {
if g.CacheTTL > 0 {
g.storeAllowed(userID)
}
return true, nil
}
}
}
return false, nil
}
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
ocs := g.ocsClient(userID)
path := "cloud/users/" + url.PathEscape(userID) + "/groups"
raw, err := ocs.Call(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
return decodeUserGroups(raw)
}
func (g *AccessGate) ocsClient(userID string) OCSClient {
c := g.OCS
if c.Cred.BaseURL == "" {
c.Cred = g.Cred
}
c.Cred = c.Cred.WithUser(userID)
if c.Client == nil {
c.Client = g.Client
}
return c
}
func decodeUserGroups(raw []byte) ([]string, error) {
var parsed struct {
OCS struct {
Data struct {
Groups []string `json:"groups"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("user groups decode: %w", err)
}
return parsed.OCS.Data.Groups, nil
}
func (g *AccessGate) cachedAllowed(userID string) bool {
g.mu.Lock()
defer g.mu.Unlock()
ent, ok := g.cache[userID]
if !ok {
return false
}
if g.Now().After(ent.until) {
delete(g.cache, userID)
return false
}
return true
}
func (g *AccessGate) storeAllowed(userID string) {
g.mu.Lock()
defer g.mu.Unlock()
g.cache[userID] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
}
func (g AccessGate) normalized() *AccessGate {
out := g
if out.cache == nil {
out.cache = make(map[string]cacheEntry)
}
if out.Now == nil {
out.Now = time.Now
}
return &out
}
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
if acceptsHTML(r.Header.Get("Accept")) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(deniedHTML)
return
}
http.Error(w, "forbidden", http.StatusForbidden)
}
func acceptsHTML(accept string) bool {
return strings.Contains(strings.ToLower(accept), "text/html")
}
func (g *AccessGate) shouldSkip(path string) bool {
path = strings.TrimSuffix(path, "/")
if path == "" {
path = "/"
}
for _, p := range defaultSkipPaths {
if path == p {
return true
}
}
for _, p := range g.ExtraSkipPaths {
p = strings.TrimSuffix(p, "/")
if p == "" {
p = "/"
}
if path == p {
return true
}
}
return false
}
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
var deniedHTML = []byte(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Access denied</title></head>
<body><h1>Access denied</h1><p>You are not a member of a required group for this app.</p></body>
</html>
`)