139 lines
3.8 KiB
Go
139 lines
3.8 KiB
Go
package authproxy
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"log"
|
|
"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
|
|
|
|
// Unreadable records that one of the app's list files exists but could not
|
|
// be read, leaving its real rules unknown. Falling back to the global list
|
|
// would then silently widen access — the app is likely narrower than global,
|
|
// which is why it has a list at all — so an app in this state denies
|
|
// everyone until the file is readable again.
|
|
Unreadable bool
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var lists accessLists
|
|
read := func(name string) []string {
|
|
entries, err := readListFile(filepath.Join(s.dir, app, name))
|
|
if err != nil {
|
|
log.Printf("appconfig: cannot read %s for app %q: %v — denying every account "+
|
|
"for that app until it is readable", name, app, err)
|
|
lists.Unreadable = true
|
|
}
|
|
return entries
|
|
}
|
|
lists.AllowedDomains = trimAts(read("allowed-domains"))
|
|
lists.AllowedEmails = read("allowed-emails")
|
|
lists.DeniedEmails = read("denied-emails")
|
|
|
|
s.cache[app] = cachedLists{lists: lists, loadedAt: s.now()}
|
|
return lists
|
|
}
|
|
|
|
// readListFile returns the entries in a list file. A missing file is normal —
|
|
// most apps have no overrides — and reads as an empty list; any other error is
|
|
// reported, because it means the app's rules are unknown rather than absent.
|
|
func readListFile(path string) ([]string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return splitList(string(raw)), nil
|
|
}
|
|
|
|
// trimAts accepts domains written either as "signal.org" or "@signal.org".
|
|
func trimAts(entries []string) []string {
|
|
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
|
|
}
|