diff --git a/CONTEXT.md b/CONTEXT.md index 1e2fa0c..68f7349 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/README.md b/README.md index 00225ec..2fc0445 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/access_gate.go b/access_gate.go new file mode 100644 index 0000000..29cf92b --- /dev/null +++ b/access_gate.go @@ -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(` + +
You are not a member of a required group for this app.
+ +`) diff --git a/access_gate_test.go b/access_gate_test.go new file mode 100644 index 0000000..e12aea3 --- /dev/null +++ b/access_gate_test.go @@ -0,0 +1,359 @@ +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 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) + } +} diff --git a/doc.go b/doc.go index f20fce2..767f281 100644 --- a/doc.go +++ b/doc.go @@ -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 diff --git a/required_groups_env.go b/required_groups_env.go new file mode 100644 index 0000000..4c444c9 --- /dev/null +++ b/required_groups_env.go @@ -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 +}