Allow per-app allow/deny.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// accessLists is one scope's worth of authorization rules — either the global
|
||||
// config or a single app's overrides.
|
||||
type accessLists struct {
|
||||
AllowedDomains []string
|
||||
AllowedEmails []string
|
||||
DeniedEmails []string
|
||||
}
|
||||
|
||||
func (a accessLists) hasAllowRules() bool {
|
||||
return len(a.AllowedDomains) > 0 || len(a.AllowedEmails) > 0
|
||||
}
|
||||
|
||||
// appStore reads per-app allow/deny lists from a directory the dokku plugin
|
||||
// bind-mounts read-only (one subdirectory per app, one file per list). Reading
|
||||
// from disk rather than the environment means the plugin can change an app's
|
||||
// lists without restarting this service and interrupting every other app.
|
||||
//
|
||||
// Results are cached for a short TTL because the lists are consulted on every
|
||||
// authenticated request.
|
||||
type appStore struct {
|
||||
dir string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedLists
|
||||
}
|
||||
|
||||
type cachedLists struct {
|
||||
lists accessLists
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
func newAppStore(dir string) *appStore {
|
||||
return &appStore{
|
||||
dir: dir,
|
||||
ttl: 2 * time.Second,
|
||||
now: time.Now,
|
||||
cache: map[string]cachedLists{},
|
||||
}
|
||||
}
|
||||
|
||||
// lists returns the app's own rules, or a zero value when the app has none,
|
||||
// the name is unusable, or per-app config is disabled. A zero value means
|
||||
// "inherit the global config", so every failure path is a safe fallback.
|
||||
func (s *appStore) lists(app string) accessLists {
|
||||
if s == nil || s.dir == "" || !validAppName(app) {
|
||||
return accessLists{}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if hit, ok := s.cache[app]; ok && s.now().Sub(hit.loadedAt) < s.ttl {
|
||||
return hit.lists
|
||||
}
|
||||
|
||||
lists := accessLists{
|
||||
AllowedDomains: readDomainFile(filepath.Join(s.dir, app, "allowed-domains")),
|
||||
AllowedEmails: readListFile(filepath.Join(s.dir, app, "allowed-emails")),
|
||||
DeniedEmails: readListFile(filepath.Join(s.dir, app, "denied-emails")),
|
||||
}
|
||||
s.cache[app] = cachedLists{lists: lists, loadedAt: s.now()}
|
||||
return lists
|
||||
}
|
||||
|
||||
func readListFile(path string) []string {
|
||||
// A missing or unreadable file is normal: most apps have no overrides.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitList(string(raw))
|
||||
}
|
||||
|
||||
func readDomainFile(path string) []string {
|
||||
entries := readListFile(path)
|
||||
for i, d := range entries {
|
||||
entries[i] = strings.TrimPrefix(d, "@")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// validAppName guards the path built from an nginx-supplied header. dokku app
|
||||
// names are lowercase alphanumerics with dashes, dots, and underscores; this
|
||||
// deliberately rejects anything that could escape the config directory.
|
||||
func validAppName(app string) bool {
|
||||
if app == "" || len(app) > 100 {
|
||||
return false
|
||||
}
|
||||
if app == "." || app == ".." || strings.HasPrefix(app, ".") {
|
||||
return false
|
||||
}
|
||||
for _, r := range app {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user