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 }