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:
+216
@@ -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>
|
||||
`)
|
||||
Reference in New Issue
Block a user