Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984b0c2335 | ||
|
|
198ef0e204 | ||
|
|
cf3b396398 | ||
|
|
69af4d19c8 | ||
|
|
92d8efea47 | ||
|
|
3980263146 |
+13
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
`)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user