Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [0.0.3-beta] - 2026-03-24

### Breaking changes

- **User / OAuth config keys** (YAML, JSON, and `/api/config` payloads) use a consistent `oauth_*` prefix:
- `admin_emails` → `oauth_admin_emails` — OAuth accounts with admin access (and included in the sign-in allowlist).
- `allowed_oauth_emails` → `oauth_allowed_emails` — OAuth accounts that may sign in without admin (union with `oauth_admin_emails`).
- `allow_all_oauth_users` → `oauth_allow_all_users` — when `true`, any OAuth user with an email may sign in; email lists are ignored for sign-in (admin access still follows `oauth_admin_emails`).
- **Environment variables**:
- `ADMIN_EMAILS` → `OAUTH_ADMIN_EMAILS` (overrides `users.oauth_admin_emails`).
- `ALLOWED_OAUTH_EMAILS` → `OAUTH_ALLOWED_EMAILS` (overrides `users.oauth_allowed_emails`).
- `OAUTH_ALLOW_ALL_USERS` is unchanged and overrides `users.oauth_allow_all_users`.

There is no automatic migration: update config files, env vars, and any automation that referenced the old names.

### Added

- OAuth sign-in **allowlist**: only emails in `oauth_admin_emails` ∪ `oauth_allowed_emails` can complete OAuth login unless `oauth_allow_all_users` is enabled.
- **Settings** UI and config schema for `oauth_allowed_emails` and `oauth_allow_all_users`, with a warning when OAuth is enabled but no allowlist is configured.
- **Startup logging** when Google/GitHub OAuth is enabled but the allowlist is empty (or when open OAuth sign-in is enabled).
- **Login** error query parameters `oauth_no_allowlist` and `oauth_not_allowed` with user-facing messages.
- `AuthHandler` reads user/OAuth settings from the live `*config.Config` so admin updates apply without restart where applicable.

### Documentation

- Examples, env reference, and auth docs updated for the new keys and variables.

## [0.0.2-beta] - earlier

Prior releases; see [GitHub Releases](https://github.com/heapoftrash/filetree/releases) for tags before this changelog was added.
123 changes: 112 additions & 11 deletions app/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ type FrontendConfig struct {
}

type UsersConfig struct {
AdminEmails []string `yaml:"admin_emails" json:"admin_emails"`
LocalUsers []LocalUser `yaml:"local_users" json:"local_users"`
DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"`
OauthAdminEmails []string `yaml:"oauth_admin_emails" json:"oauth_admin_emails"` // OAuth admins; union with oauth_allowed_emails for sign-in allowlist
OauthAllowedEmails []string `yaml:"oauth_allowed_emails" json:"oauth_allowed_emails"` // non-admin OAuth users allowed to sign in
OauthAllowAllUsers bool `yaml:"oauth_allow_all_users" json:"oauth_allow_all_users"` // if true, skip email allowlist for OAuth sign-in (oauth_admin_emails still gates admin)
LocalUsers []LocalUser `yaml:"local_users" json:"local_users"`
DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"`
}

type LocalUser struct {
Expand All @@ -86,6 +88,11 @@ type DefaultAdminUser struct {
Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext
}

func parseBoolEnv(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
return s == "1" || s == "true" || s == "yes" || s == "on"
}

// isBcryptHash returns true if s looks like a bcrypt hash ($2a$, $2b$, $2y$).
func isBcryptHash(s string) bool {
return len(s) >= 60 && (strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$"))
Expand Down Expand Up @@ -146,8 +153,14 @@ func Load(configPath string) (*Config, error) {
if len(c.Frontend.CORSOrigins) > 0 {
src.set("frontend.cors_origins", SourceConfig)
}
if len(c.Users.AdminEmails) > 0 {
src.set("users.admin_emails", SourceConfig)
if len(c.Users.OauthAdminEmails) > 0 {
src.set("users.oauth_admin_emails", SourceConfig)
}
if len(c.Users.OauthAllowedEmails) > 0 {
src.set("users.oauth_allowed_emails", SourceConfig)
}
if c.Users.OauthAllowAllUsers {
src.set("users.oauth_allow_all_users", SourceConfig)
}
if c.Auth.Providers != nil {
if p, ok := c.Auth.Providers["google"]; ok && p.ClientID != "" {
Expand Down Expand Up @@ -228,13 +241,25 @@ func Load(configPath string) (*Config, error) {
}
src.set("frontend.cors_origins", SourceEnv)
}
if v := os.Getenv("ADMIN_EMAILS"); v != "" {
if v := os.Getenv("OAUTH_ADMIN_EMAILS"); v != "" {
parts := strings.Split(v, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
c.Users.OauthAdminEmails = parts
src.set("users.oauth_admin_emails", SourceEnv)
}
if v := os.Getenv("OAUTH_ALLOWED_EMAILS"); v != "" {
parts := strings.Split(v, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
c.Users.AdminEmails = parts
src.set("users.admin_emails", SourceEnv)
c.Users.OauthAllowedEmails = parts
src.set("users.oauth_allowed_emails", SourceEnv)
}
if v := os.Getenv("OAUTH_ALLOW_ALL_USERS"); v != "" {
c.Users.OauthAllowAllUsers = parseBoolEnv(v)
src.set("users.oauth_allow_all_users", SourceEnv)
}

// 3. Defaults
Expand All @@ -260,8 +285,14 @@ func Load(configPath string) (*Config, error) {
if src["frontend.url"] == 0 {
src.set("frontend.url", SourceDefault)
}
if src["users.admin_emails"] == 0 {
src.set("users.admin_emails", SourceDefault)
if src["users.oauth_admin_emails"] == 0 {
src.set("users.oauth_admin_emails", SourceDefault)
}
if src["users.oauth_allowed_emails"] == 0 {
src.set("users.oauth_allowed_emails", SourceDefault)
}
if src["users.oauth_allow_all_users"] == 0 {
src.set("users.oauth_allow_all_users", SourceDefault)
}
if src["server.debug"] == 0 {
c.Server.Debug = false
Expand Down Expand Up @@ -405,10 +436,80 @@ func logConfigSources(logger *log.Logger, c *Config, src sources, configPath str
{"auth.oauth_redirect_url", c.Auth.OAuthRedirectURL, src["auth.oauth_redirect_url"]},
{"frontend.url", c.Frontend.URL, src["frontend.url"]},
{"frontend.cors_origins", fmt.Sprintf("%v", c.Frontend.CORSOrigins), src["frontend.cors_origins"]},
{"users.admin_emails", fmt.Sprintf("%v", c.Users.AdminEmails), src["users.admin_emails"]},
{"users.oauth_admin_emails", fmt.Sprintf("%v", c.Users.OauthAdminEmails), src["users.oauth_admin_emails"]},
{"users.oauth_allowed_emails", fmt.Sprintf("%v", c.Users.OauthAllowedEmails), src["users.oauth_allowed_emails"]},
{"users.oauth_allow_all_users", fmt.Sprintf("%v", c.Users.OauthAllowAllUsers), src["users.oauth_allow_all_users"]},
}

for _, item := range items {
logger.Printf(" %s: %s (from %s)", item.key, item.value, item.s)
}
}

// OAuthProviderActive reports whether Google or GitHub is enabled with a client ID.
func OAuthProviderActive(c *Config) bool {
if c == nil || c.Auth.Providers == nil {
return false
}
for _, id := range []string{"google", "github"} {
p, ok := c.Auth.Providers[id]
if ok && p.Enabled && strings.TrimSpace(p.ClientID) != "" {
return true
}
}
return false
}

// OAuthLoginAllowlistConfigured returns true if OAuth sign-in is allowed without empty-list denial:
// oauth_allow_all_users, or at least one non-empty email in oauth_admin_emails or oauth_allowed_emails.
func OAuthLoginAllowlistConfigured(c *Config) bool {
if c == nil {
return false
}
if c.Users.OauthAllowAllUsers {
return true
}
for _, e := range c.Users.OauthAdminEmails {
if strings.TrimSpace(e) != "" {
return true
}
}
for _, e := range c.Users.OauthAllowedEmails {
if strings.TrimSpace(e) != "" {
return true
}
}
return false
}

// UserIsAdmin reports whether identity (OAuth email or local username from JWT) has admin
// privileges for the given live config: oauth_admin_emails, local_users with is_admin, or
// default_admin when its password is set.
func UserIsAdmin(c *Config, identity string) bool {
if c == nil {
return false
}
identity = strings.TrimSpace(identity)
if identity == "" {
return false
}
for _, e := range c.Users.OauthAdminEmails {
if strings.EqualFold(strings.TrimSpace(e), identity) {
return true
}
}
localUsers := c.Users.LocalUsers
if localUsers == nil {
localUsers = []LocalUser{}
}
for _, u := range localUsers {
if strings.EqualFold(u.Username, identity) && u.IsAdmin {
return true
}
}
da := c.Users.DefaultAdmin
if da != nil && da.Password != "" && strings.EqualFold(da.Username, identity) {
return true
}
return false
}
36 changes: 36 additions & 0 deletions app/config/live.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package config

import "sync/atomic"

// LiveConfig holds the current application config with atomic snapshot reads and publishes.
// Callers must treat Snapshot() return values as read-only; Replace swaps in a new pointer
// that must not be mutated after publish.
type LiveConfig struct {
v atomic.Value // *Config
}

// NewLiveConfig wraps cfg for concurrent access. The same pointer is stored until the first Replace.
func NewLiveConfig(cfg *Config) *LiveConfig {
l := &LiveConfig{}
if cfg == nil {
l.v.Store(&Config{})
} else {
l.v.Store(cfg)
}
return l
}

// Snapshot returns the current config for read-only use. It is safe to call from any goroutine
// concurrently with Replace: each load observes one complete published config.
func (l *LiveConfig) Snapshot() *Config {
return l.v.Load().(*Config)
}

// Replace publishes a new config. c must not be mutated after this call.
func (l *LiveConfig) Replace(c *Config) {
if c == nil {
l.v.Store(&Config{})
return
}
l.v.Store(c)
}
34 changes: 34 additions & 0 deletions app/config/live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import (
"sync"
"testing"
)

func TestLiveConfigReplaceSnapshot(t *testing.T) {
a := &Config{Users: UsersConfig{OauthAdminEmails: []string{"a@b.c"}}}
l := NewLiveConfig(a)
if s := l.Snapshot(); len(s.Users.OauthAdminEmails) != 1 {
t.Fatalf("snapshot: %v", s.Users.OauthAdminEmails)
}
b := &Config{Users: UsersConfig{OauthAdminEmails: []string{"x@y.z"}}}
l.Replace(b)
if s := l.Snapshot(); len(s.Users.OauthAdminEmails) != 1 || s.Users.OauthAdminEmails[0] != "x@y.z" {
t.Fatalf("after replace: %v", s.Users.OauthAdminEmails)
}
}

func TestLiveConfigConcurrentReplace(t *testing.T) {
l := NewLiveConfig(&Config{})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c := &Config{Users: UsersConfig{OauthAdminEmails: []string{"u@x.y"}}}
l.Replace(c)
_ = l.Snapshot().Users.OauthAdminEmails
}()
}
wg.Wait()
}
70 changes: 70 additions & 0 deletions app/config/oauth_allowlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import "testing"

func TestOAuthProviderActive(t *testing.T) {
c := &Config{}
if OAuthProviderActive(c) {
t.Fatal("expected false for nil providers")
}
c.Auth.Providers = map[string]ProviderConfig{
"google": {Enabled: true, ClientID: "x"},
}
if !OAuthProviderActive(c) {
t.Fatal("expected true when google enabled with client id")
}
}

func TestOAuthLoginAllowlistConfigured(t *testing.T) {
if OAuthLoginAllowlistConfigured(nil) {
t.Fatal("nil config")
}
c := &Config{Users: UsersConfig{OauthAdminEmails: []string{" "}}}
if OAuthLoginAllowlistConfigured(c) {
t.Fatal("whitespace only should not count")
}
c.Users.OauthAdminEmails = []string{"a@b.c"}
if !OAuthLoginAllowlistConfigured(c) {
t.Fatal("admin email should count")
}
c.Users.OauthAdminEmails = nil
c.Users.OauthAllowedEmails = []string{"u@x.y"}
if !OAuthLoginAllowlistConfigured(c) {
t.Fatal("oauth_allowed_emails should count")
}
c.Users.OauthAllowedEmails = nil
c.Users.OauthAllowAllUsers = true
if !OAuthLoginAllowlistConfigured(c) {
t.Fatal("oauth_allow_all_users should satisfy allowlist check")
}
}

func TestUserIsAdmin(t *testing.T) {
if UserIsAdmin(nil, "a@b.c") {
t.Fatal("nil config")
}
c := &Config{Users: UsersConfig{OauthAdminEmails: []string{"Admin@x.com"}}}
if !UserIsAdmin(c, "admin@x.com") {
t.Fatal("oauth admin email")
}
if UserIsAdmin(c, "other@x.com") {
t.Fatal("non-admin oauth email")
}
c.Users.OauthAdminEmails = nil
c.Users.LocalUsers = []LocalUser{{Username: "alice", IsAdmin: true}}
if !UserIsAdmin(c, "alice") {
t.Fatal("local admin username")
}
if UserIsAdmin(c, "bob") {
t.Fatal("non-admin local user")
}
c.Users.LocalUsers = []LocalUser{{Username: "alice", IsAdmin: false}}
c.Users.DefaultAdmin = &DefaultAdminUser{Username: "bootstrap", Password: "hashed"}
if !UserIsAdmin(c, "bootstrap") {
t.Fatal("default admin when password set")
}
c.Users.DefaultAdmin.Password = ""
if UserIsAdmin(c, "bootstrap") {
t.Fatal("default admin without password should not grant admin")
}
}
4 changes: 3 additions & 1 deletion app/config/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ var ConfigFields = []FieldMeta{
{Section: "auth", Key: "jwt_secret_set", Kind: FieldBool, Label: "JWT secret", Editable: false, Secret: true},
{Section: "auth", Key: "local_auth_enabled", Kind: FieldBool, Label: "Local users enabled", Editable: true},
// Users
{Section: "users", Key: "admin_emails", Kind: FieldStringSlice, Label: "Admin emails (OAuth)", Editable: true},
{Section: "users", Key: "oauth_admin_emails", Kind: FieldStringSlice, Label: "Admins (OAuth)", Editable: true, Extra: "Full admin access. These addresses can sign in with Google or GitHub."},
{Section: "users", Key: "oauth_allowed_emails", Kind: FieldStringSlice, Label: "Additional sign-ins (OAuth)", Editable: true, Extra: "Regular users who may sign in with OAuth (not admins)."},
{Section: "users", Key: "oauth_allow_all_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "Any OAuth user with an email can sign in; the lists above are ignored for sign-in. Admin access still follows the admin list only. Trusted environments only."},
{Section: "users", Key: "local_users", Kind: FieldObjectSlice, Label: "Local users", Editable: true},
{Section: "users", Key: "default_admin_username", Kind: FieldString, Label: "Default admin username", Editable: true, Placeholder: "admin"},
{Section: "users", Key: "default_admin_password", Kind: FieldString, Label: "Default admin password", Editable: true, Secret: true, Placeholder: "Only used when no users exist"},
Expand Down
Loading
Loading