Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3980263146 |
+9
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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>
|
||||
`)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user