6 Commits
Author SHA1 Message Date
Konrad NeitzelandCursor 984b0c2335 Skip /js/ in the Access Gate and set denied-page CSP so Denied UI can render.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:39:47 +02:00
Konrad Neitzel 198ef0e204 Merge branch 'feature/go-library-docs' 2026-08-27 22:00:10 +02:00
Konrad NeitzelandCursor cf3b396398 Document consumer README skeleton and add package Examples.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:00:02 +02:00
Konrad NeitzelandCursor 69af4d19c8 Serve Access Gate denied HTML as 200 for iframe display.
AppAPI sets frame-ancestors none on 403 proxy responses, which blanked the ExApp iframe; keep API denials as 403.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:43:01 +02:00
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
9 changed files with 927 additions and 9 deletions
+13 -1
View File
@@ -1,6 +1,6 @@
# go-nc-exapp # 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 ## Language
@@ -19,3 +19,15 @@ _Avoid_: settings file in User Files, instance-wide config
**OCS**: **OCS**:
Nextcloud's legacy HTTP API surface under `/ocs/v2.php/…`. This Library requests JSON responses (`format=json`) for machine-readable bodies. 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 _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 and top-menu script URLs under `/js/` stay ungated so Denied UI can load in the Nextcloud shell.
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
**Top Menu visibility**:
Whether the ExApp app icon in the Nextcloud top menu is shown to all logged-in users or to Nextcloud admins only. Configured per deploy via env `TOP_MENU_ADMIN_REQUIRED` (`0` or `1`); the ExApp passes the value to AppAPI when registering the top-menu entry on enable. Independent of route `access_level` in info.xml and of Required Groups.
_Avoid_: route access_level, Required Groups, AppAPI group ACL
+46 -6
View File
@@ -1,10 +1,10 @@
# go-nc-exapp # 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 an optional Required Groups Access Gate.
Import: `gitea.neitzel.de/konrad/go-nc-exapp` Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
## v1 scope ## Scope
**Included** **Included**
@@ -14,10 +14,12 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp`
- **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests - **UserFromRequest** — extract the requesting user from inbound AppAPI-proxied requests
- **OCSClient** — authenticated OCS calls that always append `format=json` - **OCSClient** — authenticated OCS calls that always append `format=json`
- **AppAPIPreferences** — parameterized get/set of a string ExApp preference (caller supplies app id and key) - **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 for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
**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 - HaRP listen / `serve()` and unix-socket bootstrap
- Top-menu, script, and iframe UI registration - Top-menu, script, and iframe UI registration
- WebDAV and file storage (see **go-nc-files**) - WebDAV and file storage (see **go-nc-files**)
@@ -37,15 +39,53 @@ cred := gonexapp.Credentials{
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault") prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
value, err := prefs.Get() 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. 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.
The Gate skips `/heartbeat`, `/enabled`, `/init`, and any path under **`/js/`** (AppAPI top-menu bootstrap). Serve the registered top-menu script under `/js/…` so a non-member still loads it and can show Denied UI in the Nextcloud shell. API routes stay gated.
Denied HTML is **200** with `Content-Security-Policy: … frame-ancestors 'self'`. Without that header AppAPI’s proxy defaults to `frame-ancestors 'none'` and a denied iframe stays blank. Non-HTML denials remain **403**.
### Top Menu visibility (`TOP_MENU_ADMIN_REQUIRED`)
Declare in `info.xml` under `<environment-variables>`. At enable time the ExApp reads the env and passes `"0"` or `"1"` to AppAPI’s top-menu OCS `adminRequired`. Only `0` and `1` are valid; anything else falls back to `DefaultTopMenuAdminRequired` (`true` → admins only).
```go
adminRequired := gonexapp.TopMenuAdminRequired(
os.Getenv(gonexapp.EnvTopMenuAdminRequired),
gonexapp.DefaultTopMenuAdminRequired,
)
// use adminRequired in POST …/ui/top-menu when registering the menu entry
```
**Applying a change:** AppAPI registers the top menu when the ExApp receives `PUT /enabled?enabled=1`. Changing the deploy env alone does not update the menu entry.
1. Set the new value in Deploy options (UI) or `occ app_api:app:register … --env TOP_MENU_ADMIN_REQUIRED=…` / update deploy config.
2. Recreate or restart the ExApp container so the new env is present.
3. Re-run lifecycle: disable then enable the ExApp (UI or `occ app_api:app:disable` / `app_api:app:enable`), or `occ app_api:app:update … -e` after an image/info update.
Route `access_level` in `info.xml` is separate and only changes when AppAPI re-reads `info.xml` on register/update — not via this env.
Runnable package examples: `go test -run Example`.
## Domain language ## 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, Access Gate, and Top Menu visibility terminology.
## Testing
Unit tests use `httptest` fake OCS servers. No live Nextcloud is required for Library CI.
## Related ## Related
- **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution - **go-nc-files** — WebDAV, Working Folder, Saved Default, Visit resolution
- Workspace ADR 0013 — extraction from CheckDNS - Workspace ADR 0013 — extraction from CheckDNS
- Workspace ADR `docs/adr/go-nc-exapp/0001-required-groups-access-gate.md` — Access Gate decisions
+254
View File
@@ -0,0 +1,254 @@
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 means the request may proceed (or the gate is inactive / skipped).
CheckAllowed CheckResult = iota
// CheckDenied means the Requesting user is not in Required Groups.
CheckDenied
// CheckUnauthorized means no Requesting user could be read from the request.
CheckUnauthorized
// CheckUnavailable means group membership could not be determined (e.g. OCS error).
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 plus frame-ancestors 'self': AppAPI's default proxy CSP uses
// frame-ancestors 'none' unless the ExApp sets CSP, which blanks iframes.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", deniedCSP)
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)
if isTopMenuScriptPath(path) {
return true
}
for _, p := range defaultSkipPaths {
if path == p {
return true
}
}
for _, p := range g.ExtraSkipPaths {
if path == normalizeGatePath(p) {
return true
}
}
return false
}
// isTopMenuScriptPath reports AppAPI top-menu bootstrap scripts under /js/.
// Those must load for a non-member so the shell can show Denied UI; gating
// them yields a blank embedded page (script Accept is not text/html → 403).
func isTopMenuScriptPath(path string) bool {
return path == "/js" || strings.HasPrefix(path, "/js/")
}
func normalizeGatePath(path string) string {
path = strings.TrimSuffix(path, "/")
if path == "" {
return "/"
}
return path
}
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
// deniedCSP lets AppAPI proxy the denied page into an ExApp iframe.
const deniedCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; style-src 'unsafe-inline'"
var deniedHTML = []byte(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Access denied</title>
<style>
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; }
body { margin: 2rem; max-width: 40rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
p { color: #444; line-height: 1.5; }
</style>
</head>
<body>
<h1>Access denied</h1>
<p>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.</p>
</body>
</html>
`)
+413
View File
@@ -0,0 +1,413 @@
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.StatusOK {
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())
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self'") {
t.Fatalf("csp=%q", csp)
}
}
func TestAccessGateSkipsTopMenuScriptPrefix(t *testing.T) {
gate := gonexapp.AccessGate{Groups: []string{"dns-ops"}}
h := gate.Wrap(okInner())
for _, path := range []string{"/js/checkdns-main.js", "/js/app.js", "/js"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("%s: got %d %q", path, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/json", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("/json should stay gated, got %d", rec.Code)
}
}
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 // Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
// preferences for Nextcloud ExApp Services. // preferences, and an optional Required Groups Access Gate for Nextcloud ExApp Services.
package gonexapp package gonexapp
+96
View File
@@ -0,0 +1,96 @@
package gonexapp_test
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
)
func ExampleUserFromRequest() {
token := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("AUTHORIZATION-APP-API", token)
user, err := gonexapp.UserFromRequest(req)
if err != nil {
fmt.Println("err:", err)
return
}
fmt.Println(user)
// Output: alice
}
func ExampleCredentials_AuthHeaders() {
cred := gonexapp.Credentials{
BaseURL: "https://nextcloud.example", AppID: "myexapp", AppVersion: "0.1.0",
AAVersion: "1.0.0", AppSecret: "s", UserID: "alice",
}
h := cred.AuthHeaders()
fmt.Println(h.Get("EX-APP-ID"), h.Get("OCS-APIRequest") != "")
// Output: myexapp true
}
func ExampleNewAppAPIPreferences() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "get-values") {
_, _ = io.WriteString(w, `{"ocs":{"data":[{"configkey":"savedDefault","configvalue":"Zones"}]}}`)
return
}
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"ocs":{"data":{}}}`)
}))
defer srv.Close()
cred := gonexapp.Credentials{
BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0",
AppSecret: "s", UserID: "alice",
}
prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault")
prefs.Client = srv.Client()
prefs.OCS.Client = srv.Client()
value, err := prefs.Get()
if err != nil {
fmt.Println("err:", err)
return
}
if err := prefs.Set("Zones"); err != nil {
fmt.Println("set:", err)
return
}
fmt.Println(value)
// Output: Zones
}
func ExampleAccessGate_Wrap() {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok")
})
h := gonexapp.AccessGate{}.Wrap(inner)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil))
fmt.Println(rec.Code, rec.Body.String())
// Output: 200 ok
}
func ExampleResolveRequiredGroups() {
fmt.Println(gonexapp.ResolveRequiredGroups("", false, nil))
fmt.Println(len(gonexapp.ResolveRequiredGroups("", true, []string{"ops"})))
// Output:
// []
// 0
}
func ExampleTopMenuAdminRequired() {
fmt.Println(gonexapp.TopMenuAdminRequired("0", gonexapp.DefaultTopMenuAdminRequired))
fmt.Println(gonexapp.TopMenuAdminRequired("maybe", gonexapp.DefaultTopMenuAdminRequired))
// Output:
// 0
// 1
}
+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
}
+27
View File
@@ -0,0 +1,27 @@
package gonexapp
import "strings"
// EnvTopMenuAdminRequired is the conventional deploy env name for Top Menu visibility.
// Declare it in the ExApp info.xml environment-variables section.
const EnvTopMenuAdminRequired = "TOP_MENU_ADMIN_REQUIRED"
// DefaultTopMenuAdminRequired is used when TOP_MENU_ADMIN_REQUIRED is unset or invalid.
const DefaultTopMenuAdminRequired = true
// TopMenuAdminRequired returns "1" or "0" for the AppAPI top-menu OCS adminRequired field.
// Only "0" and "1" are accepted; any other value falls back to defaultAdminRequired.
// An empty envValue means unset and also uses defaultAdminRequired.
func TopMenuAdminRequired(envValue string, defaultAdminRequired bool) string {
switch strings.TrimSpace(envValue) {
case "1":
return "1"
case "0":
return "0"
default:
if defaultAdminRequired {
return "1"
}
return "0"
}
}
+25
View File
@@ -0,0 +1,25 @@
package gonexapp
import "testing"
func TestTopMenuAdminRequired(t *testing.T) {
tests := []struct {
env string
defAdmin bool
want string
}{
{"", true, "1"},
{"", false, "0"},
{"1", true, "1"},
{"0", true, "0"},
{" 1 ", true, "1"},
{"yes", true, "1"},
{"yes", false, "0"},
{"2", true, "1"},
}
for _, tc := range tests {
if got := TopMenuAdminRequired(tc.env, tc.defAdmin); got != tc.want {
t.Errorf("TopMenuAdminRequired(%q, %v) = %q, want %q", tc.env, tc.defAdmin, got, tc.want)
}
}
}