2 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
Konrad NeitzelandCursor 3980263146 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>
2026-08-27 17:10:58 +02:00
6 changed files with 686 additions and 8 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
# go-nc-exapp
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, and per-user ExApp preferences. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
Shared Go Library for Nextcloud ExApp Services: AppAPI authentication, OCS calls, per-user ExApp preferences, and optional Required Groups gating. ExApps import `gitea.neitzel.de/konrad/go-nc-exapp`. File storage and folder visits live in go-nc-files.
## Language
@@ -19,3 +19,11 @@ _Avoid_: settings file in User Files, instance-wide config
**OCS**:
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies.
_Avoid_: assuming XML responses, REST-only Nextcloud APIs for ExApp prefs
**Required Groups**:
The Nextcloud groups configured for an ExApp (comma-separated deploy env `REQUIRED_GROUPS`) such that membership in any one of them is enough to use the ExApp. Empty or unset means no group restriction. AppAPI does not enforce this; the ExApp does.
_Avoid_: AppAPI scopes, route access_level, admin-only top menu, treating the ExApp id as an implicit group name
**Access Gate**:
The Library check that enforces Required Groups for the Requesting user on ExApp HTTP traffic (403 or denied UI when not a member; 401 without a user; 503 when membership cannot be determined). Lifecycle paths stay ungated.
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass
+14 -5
View File
@@ -1,10 +1,10 @@
# go-nc-exapp
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, and per-user ExApp preferences.
Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, and optional Required Groups Access Gate.
Import: `gitea.neitzel.de/konrad/go-nc-exapp`
## v1 scope
## Scope
**Included**
@@ -14,10 +14,11 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp`
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
- **OCSClient** — authenticated OCS calls that always append `format=json`
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key)
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML, positive membership cache; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
**Excluded from v1**
**Excluded**
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, …)
- ExApp lifecycle HTTP routes (`/heartbeat`, `/enabled`, …) — the Gate *skips* these by default but does not implement them
- HaRP listen / `serve()` and unix-socket bootstrap
- Top-menu, script, and iframe UI registration
- WebDAV and file storage (see **go-nc-files**)
@@ -37,15 +38,23 @@ cred := gonexapp.Credentials{
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
value, err := prefs.Get()
groupsEnv, groupsSet := os.LookupEnv("REQUIRED_GROUPS")
groups := gonexapp.ResolveRequiredGroups(groupsEnv, groupsSet, nil)
ttl := gonexapp.ParseCacheSeconds(os.Getenv("REQUIRED_GROUPS_CACHE_SECONDS"), gonexapp.DefaultCacheSeconds)
handler := gonexapp.AccessGate{Cred: cred, Groups: groups, CacheTTL: ttl}.Wrap(inner)
```
Each ExApp chooses its own preference keys; this library does not hardcode product-specific names.
Declare `REQUIRED_GROUPS` and `REQUIRED_GROUPS_CACHE_SECONDS` in the ExApp `info.xml` so Deploy options can set them.
## Domain language
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, requesting user, ExApp preference, and OCS terminology.
See [CONTEXT.md](./CONTEXT.md) for AppAPI credentials, Requesting user, ExApp preference, OCS, Required Groups, and Access Gate terminology.
## Related
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
- Workspace ADR 0013 — extraction from CheckDNS
- Workspace ADR `docs/adr/go-nc-exapp/0001-required-groups-access-gate.md` — Access Gate decisions
+222
View File
@@ -0,0 +1,222 @@
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")) {
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 = 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(`<!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>
`)
+388
View File
@@ -0,0 +1,388 @@
package gonexapp_test
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func authHeader(userID string) string {
return base64.StdEncoding.EncodeToString([]byte(userID + ":secret"))
}
func okInner() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
}
func TestAccessGateEmptyGroupsPassesThrough(t *testing.T) {
gate := gonexapp.AccessGate{}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
}
func TestAccessGateSkipsLifecyclePaths(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
for _, path := range []string{"/heartbeat", "/enabled", "/init"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: got %d", path, rec.Code)
}
}
}
func TestAccessGateExtraSkipPaths(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}, ExtraSkipPaths: []string{"/healthz"}}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
}
func TestAccessGateMissingUserUnauthorized(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d", rec.Code)
}
}
func groupsOCSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv
}
func gateWithOCS(t *testing.T, groups []string, ttl time.Duration, srv *httptest.Server) gonexapp.AccessGate {
t.Helper()
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "app", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s",
}
return gonexapp.AccessGate{
Cred: cred,
Groups: groups,
CacheTTL: ttl,
Client: srv.Client(),
OCS: gonexapp.OCSClient{Cred: cred, Client: srv.Client()},
}
}
func TestAccessGateAllowsAnyOfMember(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/cloud/users/alice/groups") {
http.Error(w, "bad path "+r.URL.Path, http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"other", "dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops", "dns-admins"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
if calls.Load() != 1 {
t.Fatalf("ocs calls=%d", calls.Load())
}
}
func TestAccessGateDeniesNonMemberWith403(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d", rec.Code)
}
if strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("unexpected html content-type")
}
}
func TestAccessGateDeniesNonMemberWithHTML(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"users"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
req.Header.Set("Accept", "text/html,application/xhtml+xml")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("got %d", rec.Code)
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type=%q", rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "Access denied") {
t.Fatalf("body=%q", rec.Body.String())
}
}
func TestAccessGateLookupFailureServiceUnavailable(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d", rec.Code)
}
}
func TestAccessGateCachesPositiveMembership(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"}}},
})
})
now := time.Unix(1_700_000_000, 0)
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
gate.Now = func() time.Time { return now }
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
for i := 0; i < 2; i++ {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pass %d: got %d", i, rec.Code)
}
}
if calls.Load() != 1 {
t.Fatalf("ocs calls=%d want 1", calls.Load())
}
}
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) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
groups := []string{"users"}
if n >= 2 {
groups = []string{"dns-ops"}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": groups}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("first: got %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("second: got %d", rec.Code)
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2", calls.Load())
}
}
func TestAccessGateZeroTTLDisablesCache(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"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
for i := 0; i < 2; i++ {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pass %d: got %d", i, rec.Code)
}
}
if calls.Load() != 2 {
t.Fatalf("ocs calls=%d want 2", calls.Load())
}
}
func TestAccessGateDoesNotCacheLookupErrors(t *testing.T) {
var calls atomic.Int32
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
if n == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, time.Minute, srv)
h := gate.Wrap(okInner())
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("first: got %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("second: got %d", rec.Code)
}
}
func TestParseRequiredGroups(t *testing.T) {
got := gonexapp.ParseRequiredGroups(" dns-ops, dns-admins ,, ")
if len(got) != 2 || got[0] != "dns-ops" || got[1] != "dns-admins" {
t.Fatalf("got %#v", got)
}
if len(gonexapp.ParseRequiredGroups("")) != 0 {
t.Fatalf("empty should be empty")
}
}
func TestResolveRequiredGroups(t *testing.T) {
def := []string{"checkdns"}
if got := gonexapp.ResolveRequiredGroups("", false, def); len(got) != 1 || got[0] != "checkdns" {
t.Fatalf("unset: %#v", got)
}
if got := gonexapp.ResolveRequiredGroups("", true, def); len(got) != 0 {
t.Fatalf("set empty: %#v", got)
}
if got := gonexapp.ResolveRequiredGroups("ops", true, def); len(got) != 1 || got[0] != "ops" {
t.Fatalf("set: %#v", got)
}
}
func TestParseCacheSeconds(t *testing.T) {
if d := gonexapp.ParseCacheSeconds("", 60); d != 60*time.Second {
t.Fatalf("default unset: %v", d)
}
if d := gonexapp.ParseCacheSeconds("0", 60); d != 0 {
t.Fatalf("zero: %v", d)
}
if d := gonexapp.ParseCacheSeconds("30", 60); d != 30*time.Second {
t.Fatalf("thirty: %v", d)
}
if d := gonexapp.ParseCacheSeconds("nope", 60); d != 60*time.Second {
t.Fatalf("invalid: %v", d)
}
}
func TestAccessGateCheckStandalone(t *testing.T) {
srv := groupsOCSServer(t, func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ocs": map[string]any{"data": map[string]any{"groups": []string{"dns-ops"}}},
})
})
gate := gateWithOCS(t, []string{"dns-ops"}, 0, srv)
ptr := &gonexapp.AccessGate{
Cred: gate.Cred, Groups: gate.Groups, CacheTTL: gate.CacheTTL, Client: gate.Client, OCS: gate.OCS,
}
req := httptest.NewRequest(http.MethodGet, "/api/zones", nil)
req.Header.Set("AUTHORIZATION-APP-API", authHeader("alice"))
if got := ptr.Check(req); got != gonexapp.CheckAllowed {
t.Fatalf("got %v", got)
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
// Package gonexapp provides AppAPI credentials, OCS JSON calls, and ExApp user
// preferences for Nextcloud ExApp Services.
// Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
// preferences, and an optional Required Groups Access Gate for Nextcloud ExApp Services.
package gonexapp
+51
View File
@@ -0,0 +1,51 @@
package gonexapp
import (
"strconv"
"strings"
"time"
)
// EnvRequiredGroups is the conventional deploy env name for Required Groups.
const EnvRequiredGroups = "REQUIRED_GROUPS"
// EnvRequiredGroupsCacheSeconds is the conventional deploy env name for Access Gate cache TTL.
const EnvRequiredGroupsCacheSeconds = "REQUIRED_GROUPS_CACHE_SECONDS"
// DefaultCacheSeconds is used when REQUIRED_GROUPS_CACHE_SECONDS is unset or invalid.
const DefaultCacheSeconds = 60
// ParseRequiredGroups splits a comma-separated Required Groups env value.
func ParseRequiredGroups(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
return out
}
// ResolveRequiredGroups applies env override rules: unset uses codeDefault;
// set (including empty) replaces the default.
func ResolveRequiredGroups(envValue string, envSet bool, codeDefault []string) []string {
if !envSet {
return append([]string(nil), codeDefault...)
}
return ParseRequiredGroups(envValue)
}
// ParseCacheSeconds parses REQUIRED_GROUPS_CACHE_SECONDS. Unset or invalid → defaultSec seconds; "0" → no cache.
func ParseCacheSeconds(s string, defaultSec int) time.Duration {
if strings.TrimSpace(s) == "" {
return time.Duration(defaultSec) * time.Second
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return time.Duration(defaultSec) * time.Second
}
return time.Duration(n) * time.Second
}