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) { key := g.cacheKey(userID) if g.CacheTTL > 0 { if g.cachedAllowed(key) { 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(key) } return true, 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) { 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(key string) bool { g.mu.Lock() defer g.mu.Unlock() ent, ok := g.cache[key] if !ok { return false } if g.Now().After(ent.until) { delete(g.cache, key) return false } return true } func (g *AccessGate) storeAllowed(key string) { g.mu.Lock() defer g.mu.Unlock() g.cache[key] = 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")) { // 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.WriteHeader(http.StatusOK) _, _ = 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 = normalizeGatePath(path) for _, p := range defaultSkipPaths { if path == p { return true } } for _, p := range g.ExtraSkipPaths { if path == normalizeGatePath(p) { return true } } return false } func normalizeGatePath(path string) string { path = strings.TrimSuffix(path, "/") if path == "" { return "/" } return path } var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"} var deniedHTML = []byte(` Access denied

Access denied

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.

`)