From 38da3728e16dcf60f20c9f0ecfe1e97100447674 Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Wed, 25 Mar 2026 21:08:27 +0100 Subject: [PATCH 01/11] feat(auth): OAuth allowlist with allowed_oauth_emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only emails in admin_emails ∪ allowed_oauth_emails may complete OAuth sign-in. Admin UI access remains admin_emails and local is_admin only. Empty union when OAuth is enabled denies OAuth login; startup log and Settings warning. Login page shows oauth_no_allowlist and oauth_not_allowed errors. Env: ALLOWED_OAUTH_EMAILS. AuthHandler reads live config for allowlist and /me. Closes https://github.com/heapoftrash/filetree/issues/43 --- app/config/config.go | 54 +++++++++- app/config/oauth_allowlist_test.go | 35 ++++++ app/config/registry.go | 3 +- app/handlers/auth.go | 111 +++++++++++++------- app/handlers/auth_allowlist_test.go | 23 ++++ app/handlers/config.go | 13 +++ app/main.go | 4 + app/web/src/components/Login.tsx | 3 + app/web/src/components/Settings.tsx | 85 ++++++++------- config.example.json | 1 + config.example.yaml | 3 +- docs/authentication/index.md | 3 + docs/authentication/setup.md | 7 ++ docs/configuration/config-file.md | 5 +- docs/configuration/environment-variables.md | 3 +- docs/configuration/index.md | 2 +- docs/features/secure-auth.md | 4 + 17 files changed, 273 insertions(+), 86 deletions(-) create mode 100644 app/config/oauth_allowlist_test.go create mode 100644 app/handlers/auth_allowlist_test.go diff --git a/app/config/config.go b/app/config/config.go index 092e7bf..10da9d7 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -70,9 +70,10 @@ 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"` + AdminEmails []string `yaml:"admin_emails" json:"admin_emails"` + AllowedOAuthEmails []string `yaml:"allowed_oauth_emails" json:"allowed_oauth_emails"` // non-admin OAuth users allowed to sign in (union with admin_emails) + LocalUsers []LocalUser `yaml:"local_users" json:"local_users"` + DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"` } type LocalUser struct { @@ -149,6 +150,9 @@ func Load(configPath string) (*Config, error) { if len(c.Users.AdminEmails) > 0 { src.set("users.admin_emails", SourceConfig) } + if len(c.Users.AllowedOAuthEmails) > 0 { + src.set("users.allowed_oauth_emails", SourceConfig) + } if c.Auth.Providers != nil { if p, ok := c.Auth.Providers["google"]; ok && p.ClientID != "" { src.set("auth.providers.google.client_id", SourceConfig) @@ -236,6 +240,14 @@ func Load(configPath string) (*Config, error) { c.Users.AdminEmails = parts src.set("users.admin_emails", SourceEnv) } + if v := os.Getenv("ALLOWED_OAUTH_EMAILS"); v != "" { + parts := strings.Split(v, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + c.Users.AllowedOAuthEmails = parts + src.set("users.allowed_oauth_emails", SourceEnv) + } // 3. Defaults if c.Server.RootPath == "" { @@ -263,6 +275,9 @@ func Load(configPath string) (*Config, error) { if src["users.admin_emails"] == 0 { src.set("users.admin_emails", SourceDefault) } + if src["users.allowed_oauth_emails"] == 0 { + src.set("users.allowed_oauth_emails", SourceDefault) + } if src["server.debug"] == 0 { c.Server.Debug = false src.set("server.debug", SourceDefault) @@ -406,9 +421,42 @@ func logConfigSources(logger *log.Logger, c *Config, src sources, configPath str {"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.allowed_oauth_emails", fmt.Sprintf("%v", c.Users.AllowedOAuthEmails), src["users.allowed_oauth_emails"]}, } 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 admin_emails or allowed_oauth_emails contains a non-empty email. +func OAuthLoginAllowlistConfigured(c *Config) bool { + if c == nil { + return false + } + for _, e := range c.Users.AdminEmails { + if strings.TrimSpace(e) != "" { + return true + } + } + for _, e := range c.Users.AllowedOAuthEmails { + if strings.TrimSpace(e) != "" { + return true + } + } + return false +} diff --git a/app/config/oauth_allowlist_test.go b/app/config/oauth_allowlist_test.go new file mode 100644 index 0000000..e1b9b61 --- /dev/null +++ b/app/config/oauth_allowlist_test.go @@ -0,0 +1,35 @@ +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{AdminEmails: []string{" "}}} + if OAuthLoginAllowlistConfigured(c) { + t.Fatal("whitespace only should not count") + } + c.Users.AdminEmails = []string{"a@b.c"} + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("admin email should count") + } + c.Users.AdminEmails = nil + c.Users.AllowedOAuthEmails = []string{"u@x.y"} + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("allowed_oauth_emails should count") + } +} diff --git a/app/config/registry.go b/app/config/registry.go index 00bbe7d..bd30d40 100644 --- a/app/config/registry.go +++ b/app/config/registry.go @@ -68,7 +68,8 @@ 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: "admin_emails", Kind: FieldStringSlice, Label: "Admin emails (OAuth)", Editable: true, Extra: "OAuth accounts with admin access. Also used as allowed sign-in emails (admins can always sign in)."}, + {Section: "users", Key: "allowed_oauth_emails", Kind: FieldStringSlice, Label: "Allowed OAuth emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with admin emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed."}, {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"}, diff --git a/app/handlers/auth.go b/app/handlers/auth.go index ff1bb20..d1c04a8 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -26,14 +26,9 @@ var ( ) type AuthHandler struct { - oauth2Configs map[string]*oauth2.Config // provider id -> config - jwtSecret []byte - frontendURL string - adminEmails []string - localUsers []config.LocalUser - defaultAdmin *config.DefaultAdminUser - localAuthEnabled bool - oauthProviders []LoginProviderInfo + oauth2Configs map[string]*oauth2.Config // provider id -> config + jwtSecret []byte + appCfg *config.Config // live pointer; same as ConfigHandler (admin PATCH updates allowlists) } // oauthCallbackURL derives the callback URL for a provider from the base oauth_redirect_url. @@ -87,24 +82,26 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler { } } } - localUsers := cfg.Users.LocalUsers - if localUsers == nil { - localUsers = []config.LocalUser{} - } - oauthProviders := buildOAuthProviders(cfg) - hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) return &AuthHandler{ - oauth2Configs: oauth2Configs, - jwtSecret: []byte(cfg.Auth.JWTSecret), - frontendURL: cfg.Frontend.URL, - adminEmails: cfg.Users.AdminEmails, - localUsers: localUsers, - defaultAdmin: cfg.Users.DefaultAdmin, - localAuthEnabled: hasLocalAuth, - oauthProviders: oauthProviders, + oauth2Configs: oauth2Configs, + jwtSecret: []byte(cfg.Auth.JWTSecret), + appCfg: cfg, } } +func oauthEmailAllowSet(adminEmails, allowedOAuthEmails []string) map[string]struct{} { + out := make(map[string]struct{}) + for _, list := range [][]string{adminEmails, allowedOAuthEmails} { + for _, e := range list { + e = strings.ToLower(strings.TrimSpace(e)) + if e != "" { + out[e] = struct{}{} + } + } + } + return out +} + func buildOAuthProviders(cfg *config.Config) []LoginProviderInfo { out := make([]LoginProviderInfo, 0) if cfg.Auth.Providers == nil { @@ -131,9 +128,15 @@ type LoginProviderInfo struct { // LoginOptions returns enabled auth methods (public, no JWT). func (h *AuthHandler) LoginOptions(c *gin.Context) { + cfg := h.appCfg + localUsers := cfg.Users.LocalUsers + if localUsers == nil { + localUsers = []config.LocalUser{} + } + hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) c.JSON(http.StatusOK, LoginOptionsResponse{ - LocalAuthEnabled: h.localAuthEnabled, - Providers: h.oauthProviders, + LocalAuthEnabled: hasLocalAuth, + Providers: buildOAuthProviders(cfg), }) } @@ -145,7 +148,13 @@ type LocalLoginRequest struct { // LocalLogin authenticates a local user or default admin and returns JWT. func (h *AuthHandler) LocalLogin(c *gin.Context) { - if !h.localAuthEnabled { + cfg := h.appCfg + localUsers := cfg.Users.LocalUsers + if localUsers == nil { + localUsers = []config.LocalUser{} + } + hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) + if !hasLocalAuth { c.JSON(http.StatusBadRequest, gin.H{"error": "local auth not enabled"}) return } @@ -162,24 +171,24 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { } var authUsername string // Check local_users first - for i := range h.localUsers { - if strings.EqualFold(h.localUsers[i].Username, username) { - if err := bcrypt.CompareHashAndPassword([]byte(h.localUsers[i].Password), []byte(password)); err != nil { + for i := range localUsers { + if strings.EqualFold(localUsers[i].Username, username) { + if err := bcrypt.CompareHashAndPassword([]byte(localUsers[i].Password), []byte(password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } - authUsername = h.localUsers[i].Username + authUsername = localUsers[i].Username break } } - // Check default_admin if not found in local_users - if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && - strings.EqualFold(h.defaultAdmin.Username, username) { - if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.Password), []byte(password)); err != nil { + da := cfg.Users.DefaultAdmin + if authUsername == "" && da != nil && da.Password != "" && + strings.EqualFold(da.Username, username) { + if err := bcrypt.CompareHashAndPassword([]byte(da.Password), []byte(password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } - authUsername = h.defaultAdmin.Username + authUsername = da.Username } if authUsername == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) @@ -280,6 +289,24 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { return } + appCfg := h.appCfg + emailKey := strings.ToLower(strings.TrimSpace(email)) + if emailKey == "" { + c.Redirect(http.StatusFound, h.redirectTo("/login?error=userinfo")) + return + } + allow := oauthEmailAllowSet(appCfg.Users.AdminEmails, appCfg.Users.AllowedOAuthEmails) + if len(allow) == 0 { + log.Printf("[auth] oauth login denied: no admin_emails or allowed_oauth_emails configured (provider=%s)", provider) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) + return + } + if _, ok := allow[emailKey]; !ok { + log.Printf("[auth] oauth login denied: email not in allowlist (provider=%s)", provider) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_not_allowed")) + return + } + log.Printf("[auth] %s user logged in: email=%q name=%q", provider, email, name) claims := jwt.MapClaims{ @@ -396,7 +423,7 @@ func (h *AuthHandler) fetchGitHubPrimaryEmail(_ context.Context, client *http.Cl } func (h *AuthHandler) redirectTo(path string) string { - base := h.frontendURL + base := h.appCfg.Frontend.URL if base == "" || base == "/" { return path } @@ -411,27 +438,33 @@ func (h *AuthHandler) redirectTo(path string) string { // Me returns the current user (requires auth middleware). func (h *AuthHandler) Me(c *gin.Context) { + cfg := h.appCfg email, _ := c.Get("user_email") name, _ := c.Get("user_name") picture, _ := c.Get("user_picture") emailStr, _ := email.(string) isAdmin := false - for _, e := range h.adminEmails { + for _, e := range cfg.Users.AdminEmails { if strings.EqualFold(strings.TrimSpace(e), emailStr) { isAdmin = true break } } if !isAdmin { - for _, u := range h.localUsers { + localUsers := cfg.Users.LocalUsers + if localUsers == nil { + localUsers = []config.LocalUser{} + } + for _, u := range localUsers { if strings.EqualFold(u.Username, emailStr) && u.IsAdmin { isAdmin = true break } } } - if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && - strings.EqualFold(h.defaultAdmin.Username, emailStr) { + da := cfg.Users.DefaultAdmin + if !isAdmin && da != nil && da.Password != "" && + strings.EqualFold(da.Username, emailStr) { isAdmin = true } c.JSON(http.StatusOK, gin.H{ diff --git a/app/handlers/auth_allowlist_test.go b/app/handlers/auth_allowlist_test.go new file mode 100644 index 0000000..87296c5 --- /dev/null +++ b/app/handlers/auth_allowlist_test.go @@ -0,0 +1,23 @@ +package handlers + +import "testing" + +func TestOAuthEmailAllowSet(t *testing.T) { + m := oauthEmailAllowSet([]string{" A@x.com ", "b@y.com"}, []string{"B@y.com", ""}) + if len(m) != 2 { + t.Fatalf("expected 2 unique keys, got %d: %v", len(m), m) + } + if _, ok := m["a@x.com"]; !ok { + t.Fatal("missing a@x.com") + } + if _, ok := m["b@y.com"]; !ok { + t.Fatal("missing b@y.com") + } +} + +func TestOAuthEmailAllowSet_emptyLists(t *testing.T) { + m := oauthEmailAllowSet(nil, nil) + if len(m) != 0 { + t.Fatalf("expected empty set, got %v", m) + } +} diff --git a/app/handlers/config.go b/app/handlers/config.go index 89c687d..877b30c 100644 --- a/app/handlers/config.go +++ b/app/handlers/config.go @@ -171,6 +171,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { "auth_providers": authProviders, "users": { "admin_emails": cfg.Users.AdminEmails, + "allowed_oauth_emails": cfg.Users.AllowedOAuthEmails, "local_users": localUsersUI, "default_admin_username": defaultAdminUsername, "default_admin_password": defaultAdminPasswordSet, // true = Set, false = Not set @@ -283,6 +284,18 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { cfg.Users.AdminEmails = emails } } + if v, ok := s["allowed_oauth_emails"]; ok { + if arr, ok := toStringSlice(v); ok { + emails := make([]string, 0, len(arr)) + for _, e := range arr { + es := strings.TrimSpace(e) + if es != "" { + emails = append(emails, es) + } + } + cfg.Users.AllowedOAuthEmails = emails + } + } if v, ok := s["local_users"]; ok { if arr, ok := toLocalUsers(v, cfg.Users.LocalUsers); ok { cfg.Users.LocalUsers = arr diff --git a/app/main.go b/app/main.go index 42441bb..449c4d5 100644 --- a/app/main.go +++ b/app/main.go @@ -20,6 +20,10 @@ func main() { log.Fatalf("config bootstrap: %v", err) } + if config.OAuthProviderActive(cfg) && !config.OAuthLoginAllowlistConfigured(cfg) { + log.Println("[config] OAuth provider(s) are enabled but users.admin_emails and users.allowed_oauth_emails are empty — OAuth sign-in will be denied until at least one email is listed.") + } + if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil { log.Fatalf("mkdir root: %v", err) } diff --git a/app/web/src/components/Login.tsx b/app/web/src/components/Login.tsx index ff93b71..4ecec37 100644 --- a/app/web/src/components/Login.tsx +++ b/app/web/src/components/Login.tsx @@ -15,6 +15,9 @@ const errorMessages: Record = { exchange: 'Failed to complete sign in.', userinfo: 'Failed to get user info.', token: 'Failed to create session.', + oauth_no_allowlist: + 'OAuth sign-in is not configured: add at least one admin or allowed OAuth email in Settings (or contact an administrator).', + oauth_not_allowed: 'This account is not allowed to sign in. Contact an administrator if you need access.', } export default function Login() { diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index 21cae70..371ddb9 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -16,6 +16,7 @@ import { theme, Row, Col, + Alert, } from 'antd' import { CloudServerOutlined, @@ -95,7 +96,7 @@ function buildFormValues(res: ConfigAPIResponse): Record { const field = res.schema.fields?.find((f) => f.section === section && f.key === key) if (field?.kind === 'bytes' && typeof val === 'number') { sectionOut[key] = bytesToHuman(val) - } else if (field?.kind === 'string[]' && key === 'admin_emails') { + } else if (field?.kind === 'string[]' && (key === 'admin_emails' || key === 'allowed_oauth_emails')) { const arr = Array.isArray(val) ? (val as string[]) : [] sectionOut[key] = arr.length ? arr : [''] } else if (key === 'default_admin_password') { @@ -139,7 +140,12 @@ function categoryForField(name: (string | number)[]): { if (first === 'auth' || first === 'auth_providers') return { section: 'auth_providers', authProviderSub: 'google' } if (first === 'users') { const key = name[1] - if (key === 'admin_emails' || key === 'default_admin_username' || key === 'default_admin_password') { + if ( + key === 'admin_emails' || + key === 'allowed_oauth_emails' || + key === 'default_admin_username' || + key === 'default_admin_password' + ) { return { section: 'users', usersSub: 'admin_user' } } return { section: 'users', usersSub: 'local_user' } @@ -165,12 +171,20 @@ export default function Settings() { const authProviders = Form.useWatch(['auth_providers'], form) ?? (config?.values as Record>)?.auth_providers const localUsersFormValues = (Form.useWatch(['users', 'local_users'], form) ?? []) as Array> + const adminEmailsWatch = Form.useWatch(['users', 'admin_emails'], form) + const allowedOAuthWatch = Form.useWatch(['users', 'allowed_oauth_emails'], form) const oauthEnabled = (authProviders && typeof authProviders === 'object' && ((authProviders as Record>).google?.enabled === true || (authProviders as Record>).github?.enabled === true)) ?? false + const oauthAllowlistNonEmpty = useMemo(() => { + const countNonEmpty = (arr: unknown) => + Array.isArray(arr) ? (arr as unknown[]).filter((e) => typeof e === 'string' && e.trim()).length : 0 + return countNonEmpty(adminEmailsWatch) + countNonEmpty(allowedOAuthWatch) > 0 + }, [adminEmailsWatch, allowedOAuthWatch]) + useEffect(() => { getConfig() .then((res) => { @@ -186,25 +200,6 @@ export default function Settings() { if (!config) return setSaving(true) try { - // Validate admin_emails only when OAuth is enabled - const authProvidersVal = values.auth_providers as Record> | undefined - const hasOAuth = - authProvidersVal && - (authProvidersVal.google?.enabled === true || authProvidersVal.github?.enabled === true) - const users = values.users as Record | undefined - if (hasOAuth && users?.admin_emails !== undefined) { - const emails = (Array.isArray(users.admin_emails) ? users.admin_emails : []).filter( - (e: unknown) => typeof e === 'string' && e.trim(), - ) - if (emails.length === 0) { - message.error('At least one admin email is required when OAuth is enabled') - setActiveSection('users') - setUsersSub('admin_user') - setSaving(false) - return - } - } - const payload: Record = {} const schema = config.schema @@ -309,9 +304,11 @@ export default function Settings() { const adminUserFields = useMemo(() => { const users = fieldsBySection.users ?? [] return users - .filter((f) => ['admin_emails', 'default_admin_username', 'default_admin_password'].includes(f.key)) + .filter((f) => + ['admin_emails', 'allowed_oauth_emails', 'default_admin_username', 'default_admin_password'].includes(f.key), + ) .sort((a, b) => { - const order = ['admin_emails', 'default_admin_username', 'default_admin_password'] + const order = ['admin_emails', 'allowed_oauth_emails', 'default_admin_username', 'default_admin_password'] return order.indexOf(a.key) - order.indexOf(b.key) }) }, [fieldsBySection.users]) @@ -422,6 +419,15 @@ export default function Settings() { {usersSub === 'admin_user' && ( <> + {oauthEnabled && !oauthAllowlistNonEmpty && ( + + )} {adminUserFields.map((field) => ( typeof x === 'string') - const isAdminEmails = field.key === 'admin_emails' - const adminEmailsDisabled = isAdminEmails && !oauthEnabled - const initialList = isAdminEmails && list.length === 0 ? [''] : list + const isOAuthEmailList = field.key === 'admin_emails' || field.key === 'allowed_oauth_emails' + const oauthListDisabled = isOAuthEmailList && !oauthEnabled + const initialList = isOAuthEmailList && list.length === 0 ? [''] : list const labelInRow = addButtonPosition === 'right' return ( Enable an OAuth provider (Google or GitHub) to add admin emails. : undefined} + extra={ + oauthListDisabled ? ( + Enable an OAuth provider (Google or GitHub) to configure OAuth email lists. + ) : field.extra ? ( + {field.extra} + ) : undefined + } style={{ marginBottom: 12 }} > @@ -642,11 +653,8 @@ const ConfigField = React.memo(function ConfigField({ <> {labelInRow && (
- - {field.label} - {isAdminEmails && oauthEnabled && *} - -
@@ -657,17 +665,16 @@ const ConfigField = React.memo(function ConfigField({ - @@ -675,7 +682,7 @@ const ConfigField = React.memo(function ConfigField({ {!labelInRow && (
-
diff --git a/config.example.json b/config.example.json index c7c5ae0..591ac23 100644 --- a/config.example.json +++ b/config.example.json @@ -22,6 +22,7 @@ }, "users": { "admin_emails": [], + "allowed_oauth_emails": [], "local_users": [], "default_admin": null } diff --git a/config.example.yaml b/config.example.yaml index 15f426b..c058368 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,7 +26,8 @@ frontend: url: http://localhost:5173 users: - admin_emails: [] # OAuth user emails that get admin role + admin_emails: [] # OAuth emails: admin + allowed to sign in + allowed_oauth_emails: [] # OAuth emails: non-admin users allowed to sign in (union with admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied. local_users: [] # Local username/password users # default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users) # default_admin: diff --git a/docs/authentication/index.md b/docs/authentication/index.md index a2412ba..4f07b08 100644 --- a/docs/authentication/index.md +++ b/docs/authentication/index.md @@ -217,6 +217,9 @@ An example config of all authentication methods !!! note "OAuth admins" `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. +!!! note "OAuth allowlist" + `allowed_oauth_emails` lists non-admin OAuth users who may sign in. The allowlist is `admin_emails` ∪ `allowed_oauth_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked. + !!! note "OAuth redirect URL" - `oauth_redirect_url` must contain `https://your-domain.com/api/auth/` Filetree replace the path with `https://your-domain.com/api/auth/{provider}/callback`. diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md index 840da4b..5d910cc 100644 --- a/docs/authentication/setup.md +++ b/docs/authentication/setup.md @@ -82,6 +82,9 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. !!! note "OAuth admins only" `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, use `is_admin: true` in `local_users`. +!!! note "OAuth who may sign in" + `allowed_oauth_emails` lists OAuth users who may sign in **without** admin. Together with `admin_emails`, they form the OAuth allowlist. If OAuth is enabled and both lists are empty, no OAuth sign-in is allowed. + === "Config file" OAuth admins (by email): @@ -91,6 +94,8 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. admin_emails: - admin@example.com - other-admin@example.com + allowed_oauth_emails: + - user@example.com ``` Local admins (per-user): @@ -108,9 +113,11 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. | Variable | Overrides | Purpose | |----------|-----------|---------| | `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails | + | `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth non-admin allowed emails | ```bash export ADMIN_EMAILS="admin@example.com,other@example.com" + export ALLOWED_OAUTH_EMAILS="user@example.com" ``` Local admins must use `is_admin: true` in config; no env override. diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index 3f425cc..b5eb5b8 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -52,7 +52,8 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j | Key | Type | Description | |-----|------|-------------| -| `admin_emails` | []string | OAuth user emails that get admin role | +| `admin_emails` | []string | OAuth emails with admin access; also counted as allowed to sign in | +| `allowed_oauth_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected | | `local_users` | []object | `{username, password, is_admin}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | | `default_admin` | object | `{username, password}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | @@ -84,6 +85,7 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j users: admin_emails: [] + allowed_oauth_emails: [] local_users: [] default_admin: username: admin @@ -117,6 +119,7 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j }, "users": { "admin_emails": [], + "allowed_oauth_emails": [], "local_users": [] } } diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index ffdfab7..c653410 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -38,7 +38,8 @@ Environment variables override config file values. Useful for secrets and deploy | Variable | Overrides | Description | |----------|-----------|-------------| -| `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated admin emails (OAuth users) | +| `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails (admin + may sign in) | +| `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth emails allowed to sign in without admin | ## Startup logging diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 033c6f2..e3af8f3 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -111,4 +111,4 @@ Configuration is loaded in this order: **environment variables** override **conf - **Server** — `root_path`, `max_upload_bytes`, `debug` - **Auth** — `jwt_secret`, `oauth_redirect_url`, `local_auth_enabled`, `providers` (google, github) - **Frontend** — `url`, `cors_origins` -- **Users** — `admin_emails`, `local_users`, `default_admin` +- **Users** — `admin_emails`, `allowed_oauth_emails`, `local_users`, `default_admin` diff --git a/docs/features/secure-auth.md b/docs/features/secure-auth.md index 075b53c..0aa9030 100644 --- a/docs/features/secure-auth.md +++ b/docs/features/secure-auth.md @@ -23,6 +23,10 @@ You can enable multiple providers; users choose which one to use at login. - **Storage** — Tokens are kept in memory (or localStorage, depending on frontend implementation). No server-side session store. - **Usage** — Send the token as `Authorization: Bearer ` or as `?token=` for GET requests (e.g. preview links). +## OAuth allowlist + +Only addresses in **`admin_emails`** ∪ **`allowed_oauth_emails`** can complete OAuth sign-in. `admin_emails` grants **admin** access; `allowed_oauth_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. + ## Admin access Users listed in `admin_emails` (for OAuth) or with `is_admin: true` (for local users) can access the admin UI to manage auth providers and local users. A `default_admin` user can be bootstrapped on first run when no users exist. From 8e5127ddc67fcc691aedf5054b07c47bd220f278 Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Wed, 25 Mar 2026 22:19:07 +0100 Subject: [PATCH 02/11] fix(ui): shorten OAuth denial message on login page --- app/web/src/components/Login.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/web/src/components/Login.tsx b/app/web/src/components/Login.tsx index 4ecec37..d05028d 100644 --- a/app/web/src/components/Login.tsx +++ b/app/web/src/components/Login.tsx @@ -15,9 +15,8 @@ const errorMessages: Record = { exchange: 'Failed to complete sign in.', userinfo: 'Failed to get user info.', token: 'Failed to create session.', - oauth_no_allowlist: - 'OAuth sign-in is not configured: add at least one admin or allowed OAuth email in Settings (or contact an administrator).', - oauth_not_allowed: 'This account is not allowed to sign in. Contact an administrator if you need access.', + oauth_no_allowlist: 'User not allowed to sign in.', + oauth_not_allowed: 'User not allowed to sign in.', } export default function Login() { From 9eb5c1d914eae2fc839954bedc6aa5f9bd57aaba Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Wed, 25 Mar 2026 22:21:32 +0100 Subject: [PATCH 03/11] feat(auth): log failed OAuth and local login attempts like successes OAuth denials after userinfo use the same provider/email/name fields as successful logins, with a reason suffix. Local failures log username with invalid_credentials or unknown_user. --- app/handlers/auth.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/handlers/auth.go b/app/handlers/auth.go index d1c04a8..daa4292 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -174,6 +174,7 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { for i := range localUsers { if strings.EqualFold(localUsers[i].Username, username) { if err := bcrypt.CompareHashAndPassword([]byte(localUsers[i].Password), []byte(password)); err != nil { + log.Printf("[auth] local user login failed: username=%q reason=invalid_credentials", localUsers[i].Username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -185,12 +186,14 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { if authUsername == "" && da != nil && da.Password != "" && strings.EqualFold(da.Username, username) { if err := bcrypt.CompareHashAndPassword([]byte(da.Password), []byte(password)); err != nil { + log.Printf("[auth] local user login failed: username=%q reason=invalid_credentials", da.Username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } authUsername = da.Username } if authUsername == "" { + log.Printf("[auth] local user login failed: username=%q reason=unknown_user", username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -292,17 +295,18 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { appCfg := h.appCfg emailKey := strings.ToLower(strings.TrimSpace(email)) if emailKey == "" { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_missing", provider, email, name) c.Redirect(http.StatusFound, h.redirectTo("/login?error=userinfo")) return } allow := oauthEmailAllowSet(appCfg.Users.AdminEmails, appCfg.Users.AllowedOAuthEmails) if len(allow) == 0 { - log.Printf("[auth] oauth login denied: no admin_emails or allowed_oauth_emails configured (provider=%s)", provider) + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_no_allowlist_configured", provider, email, name) c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) return } if _, ok := allow[emailKey]; !ok { - log.Printf("[auth] oauth login denied: email not in allowlist (provider=%s)", provider) + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_not_allowed", provider, email, name) c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_not_allowed")) return } From 54e1562ddf419ecbcf18e459d12cddd21a594835 Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 10:29:02 +0100 Subject: [PATCH 04/11] feat(auth): add allow_all_oauth_users to bypass OAuth email allowlist When true, any OAuth user with a non-empty email may sign in; admin_emails still gates admin only. Env OAUTH_ALLOW_ALL_USERS. Startup logs when enabled; empty-list warning suggests this flag. Settings toggle and docs updated. --- app/config/config.go | 23 +++++++++++++++++- app/config/oauth_allowlist_test.go | 5 ++++ app/config/registry.go | 3 ++- app/handlers/auth.go | 22 +++++++++-------- app/handlers/config.go | 4 +++ app/main.go | 5 +++- app/web/src/components/Settings.tsx | 27 ++++++++++++++++----- config.example.json | 1 + config.example.yaml | 3 ++- docs/authentication/index.md | 2 +- docs/authentication/setup.md | 2 ++ docs/configuration/config-file.md | 5 +++- docs/configuration/environment-variables.md | 1 + docs/configuration/index.md | 2 +- docs/features/secure-auth.md | 2 +- 15 files changed, 83 insertions(+), 24 deletions(-) diff --git a/app/config/config.go b/app/config/config.go index 10da9d7..2ce1a24 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -72,6 +72,7 @@ type FrontendConfig struct { type UsersConfig struct { AdminEmails []string `yaml:"admin_emails" json:"admin_emails"` AllowedOAuthEmails []string `yaml:"allowed_oauth_emails" json:"allowed_oauth_emails"` // non-admin OAuth users allowed to sign in (union with admin_emails) + AllowAllOAuthUsers bool `yaml:"allow_all_oauth_users" json:"allow_all_oauth_users"` // if true, skip email allowlist for OAuth sign-in (admin_emails still gates admin) LocalUsers []LocalUser `yaml:"local_users" json:"local_users"` DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"` } @@ -87,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$")) @@ -153,6 +159,9 @@ func Load(configPath string) (*Config, error) { if len(c.Users.AllowedOAuthEmails) > 0 { src.set("users.allowed_oauth_emails", SourceConfig) } + if c.Users.AllowAllOAuthUsers { + src.set("users.allow_all_oauth_users", SourceConfig) + } if c.Auth.Providers != nil { if p, ok := c.Auth.Providers["google"]; ok && p.ClientID != "" { src.set("auth.providers.google.client_id", SourceConfig) @@ -248,6 +257,10 @@ func Load(configPath string) (*Config, error) { c.Users.AllowedOAuthEmails = parts src.set("users.allowed_oauth_emails", SourceEnv) } + if v := os.Getenv("OAUTH_ALLOW_ALL_USERS"); v != "" { + c.Users.AllowAllOAuthUsers = parseBoolEnv(v) + src.set("users.allow_all_oauth_users", SourceEnv) + } // 3. Defaults if c.Server.RootPath == "" { @@ -278,6 +291,9 @@ func Load(configPath string) (*Config, error) { if src["users.allowed_oauth_emails"] == 0 { src.set("users.allowed_oauth_emails", SourceDefault) } + if src["users.allow_all_oauth_users"] == 0 { + src.set("users.allow_all_oauth_users", SourceDefault) + } if src["server.debug"] == 0 { c.Server.Debug = false src.set("server.debug", SourceDefault) @@ -422,6 +438,7 @@ func logConfigSources(logger *log.Logger, c *Config, src sources, configPath str {"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.allowed_oauth_emails", fmt.Sprintf("%v", c.Users.AllowedOAuthEmails), src["users.allowed_oauth_emails"]}, + {"users.allow_all_oauth_users", fmt.Sprintf("%v", c.Users.AllowAllOAuthUsers), src["users.allow_all_oauth_users"]}, } for _, item := range items { @@ -443,11 +460,15 @@ func OAuthProviderActive(c *Config) bool { return false } -// OAuthLoginAllowlistConfigured returns true if admin_emails or allowed_oauth_emails contains a non-empty email. +// OAuthLoginAllowlistConfigured returns true if OAuth sign-in is allowed without empty-list denial: +// allow_all_oauth_users, or at least one non-empty email in admin_emails or allowed_oauth_emails. func OAuthLoginAllowlistConfigured(c *Config) bool { if c == nil { return false } + if c.Users.AllowAllOAuthUsers { + return true + } for _, e := range c.Users.AdminEmails { if strings.TrimSpace(e) != "" { return true diff --git a/app/config/oauth_allowlist_test.go b/app/config/oauth_allowlist_test.go index e1b9b61..1a779cd 100644 --- a/app/config/oauth_allowlist_test.go +++ b/app/config/oauth_allowlist_test.go @@ -32,4 +32,9 @@ func TestOAuthLoginAllowlistConfigured(t *testing.T) { if !OAuthLoginAllowlistConfigured(c) { t.Fatal("allowed_oauth_emails should count") } + c.Users.AllowedOAuthEmails = nil + c.Users.AllowAllOAuthUsers = true + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("allow_all_oauth_users should satisfy allowlist check") + } } diff --git a/app/config/registry.go b/app/config/registry.go index bd30d40..d138571 100644 --- a/app/config/registry.go +++ b/app/config/registry.go @@ -69,7 +69,8 @@ var ConfigFields = []FieldMeta{ {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, Extra: "OAuth accounts with admin access. Also used as allowed sign-in emails (admins can always sign in)."}, - {Section: "users", Key: "allowed_oauth_emails", Kind: FieldStringSlice, Label: "Allowed OAuth emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with admin emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed."}, + {Section: "users", Key: "allowed_oauth_emails", Kind: FieldStringSlice, Label: "Allowed OAuth emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with admin emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed (unless Allow all OAuth users is on)."}, + {Section: "users", Key: "allow_all_oauth_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "If enabled, any OAuth user with an email may sign in; email lists are ignored for sign-in. Use only in trusted environments. admin_emails still grants admin access 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"}, diff --git a/app/handlers/auth.go b/app/handlers/auth.go index daa4292..1302ebc 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -299,16 +299,18 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { c.Redirect(http.StatusFound, h.redirectTo("/login?error=userinfo")) return } - allow := oauthEmailAllowSet(appCfg.Users.AdminEmails, appCfg.Users.AllowedOAuthEmails) - if len(allow) == 0 { - log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_no_allowlist_configured", provider, email, name) - c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) - return - } - if _, ok := allow[emailKey]; !ok { - log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_not_allowed", provider, email, name) - c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_not_allowed")) - return + if !appCfg.Users.AllowAllOAuthUsers { + allow := oauthEmailAllowSet(appCfg.Users.AdminEmails, appCfg.Users.AllowedOAuthEmails) + if len(allow) == 0 { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_no_allowlist_configured", provider, email, name) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) + return + } + if _, ok := allow[emailKey]; !ok { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_not_allowed", provider, email, name) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_not_allowed")) + return + } } log.Printf("[auth] %s user logged in: email=%q name=%q", provider, email, name) diff --git a/app/handlers/config.go b/app/handlers/config.go index 877b30c..5593ee4 100644 --- a/app/handlers/config.go +++ b/app/handlers/config.go @@ -172,6 +172,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { "users": { "admin_emails": cfg.Users.AdminEmails, "allowed_oauth_emails": cfg.Users.AllowedOAuthEmails, + "allow_all_oauth_users": cfg.Users.AllowAllOAuthUsers, "local_users": localUsersUI, "default_admin_username": defaultAdminUsername, "default_admin_password": defaultAdminPasswordSet, // true = Set, false = Not set @@ -296,6 +297,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { cfg.Users.AllowedOAuthEmails = emails } } + if v, ok := s["allow_all_oauth_users"].(bool); ok { + cfg.Users.AllowAllOAuthUsers = v + } if v, ok := s["local_users"]; ok { if arr, ok := toLocalUsers(v, cfg.Users.LocalUsers); ok { cfg.Users.LocalUsers = arr diff --git a/app/main.go b/app/main.go index 449c4d5..3279a46 100644 --- a/app/main.go +++ b/app/main.go @@ -21,7 +21,10 @@ func main() { } if config.OAuthProviderActive(cfg) && !config.OAuthLoginAllowlistConfigured(cfg) { - log.Println("[config] OAuth provider(s) are enabled but users.admin_emails and users.allowed_oauth_emails are empty — OAuth sign-in will be denied until at least one email is listed.") + log.Println("[config] OAuth provider(s) are enabled but users.admin_emails and users.allowed_oauth_emails are empty — OAuth sign-in will be denied until at least one email is listed (or set users.allow_all_oauth_users).") + } + if config.OAuthProviderActive(cfg) && cfg.Users.AllowAllOAuthUsers { + log.Println("[config] users.allow_all_oauth_users is enabled — any OAuth user with a verified email may sign in; admin_emails still controls admin access only.") } if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil { diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index 371ddb9..bc5cfa8 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -143,6 +143,7 @@ function categoryForField(name: (string | number)[]): { if ( key === 'admin_emails' || key === 'allowed_oauth_emails' || + key === 'allow_all_oauth_users' || key === 'default_admin_username' || key === 'default_admin_password' ) { @@ -173,17 +174,19 @@ export default function Settings() { const localUsersFormValues = (Form.useWatch(['users', 'local_users'], form) ?? []) as Array> const adminEmailsWatch = Form.useWatch(['users', 'admin_emails'], form) const allowedOAuthWatch = Form.useWatch(['users', 'allowed_oauth_emails'], form) + const allowAllOAuthWatch = Form.useWatch(['users', 'allow_all_oauth_users'], form) const oauthEnabled = (authProviders && typeof authProviders === 'object' && ((authProviders as Record>).google?.enabled === true || (authProviders as Record>).github?.enabled === true)) ?? false - const oauthAllowlistNonEmpty = useMemo(() => { + const oauthSignInOk = useMemo(() => { + if (allowAllOAuthWatch === true) return true const countNonEmpty = (arr: unknown) => Array.isArray(arr) ? (arr as unknown[]).filter((e) => typeof e === 'string' && e.trim()).length : 0 return countNonEmpty(adminEmailsWatch) + countNonEmpty(allowedOAuthWatch) > 0 - }, [adminEmailsWatch, allowedOAuthWatch]) + }, [allowAllOAuthWatch, adminEmailsWatch, allowedOAuthWatch]) useEffect(() => { getConfig() @@ -305,10 +308,22 @@ export default function Settings() { const users = fieldsBySection.users ?? [] return users .filter((f) => - ['admin_emails', 'allowed_oauth_emails', 'default_admin_username', 'default_admin_password'].includes(f.key), + [ + 'admin_emails', + 'allowed_oauth_emails', + 'allow_all_oauth_users', + 'default_admin_username', + 'default_admin_password', + ].includes(f.key), ) .sort((a, b) => { - const order = ['admin_emails', 'allowed_oauth_emails', 'default_admin_username', 'default_admin_password'] + const order = [ + 'admin_emails', + 'allowed_oauth_emails', + 'allow_all_oauth_users', + 'default_admin_username', + 'default_admin_password', + ] return order.indexOf(a.key) - order.indexOf(b.key) }) }, [fieldsBySection.users]) @@ -419,13 +434,13 @@ export default function Settings() { {usersSub === 'admin_user' && ( <> - {oauthEnabled && !oauthAllowlistNonEmpty && ( + {oauthEnabled && !oauthSignInOk && ( )} {adminUserFields.map((field) => ( diff --git a/config.example.json b/config.example.json index 591ac23..455460a 100644 --- a/config.example.json +++ b/config.example.json @@ -23,6 +23,7 @@ "users": { "admin_emails": [], "allowed_oauth_emails": [], + "allow_all_oauth_users": false, "local_users": [], "default_admin": null } diff --git a/config.example.yaml b/config.example.yaml index c058368..a6299b5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -27,7 +27,8 @@ frontend: users: admin_emails: [] # OAuth emails: admin + allowed to sign in - allowed_oauth_emails: [] # OAuth emails: non-admin users allowed to sign in (union with admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied. + allowed_oauth_emails: [] # OAuth emails: non-admin users allowed to sign in (union with admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied unless allow_all_oauth_users is true. + allow_all_oauth_users: false # If true, any OAuth user with an email may sign in (lists ignored for sign-in). Trusted environments only. local_users: [] # Local username/password users # default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users) # default_admin: diff --git a/docs/authentication/index.md b/docs/authentication/index.md index 4f07b08..3602129 100644 --- a/docs/authentication/index.md +++ b/docs/authentication/index.md @@ -218,7 +218,7 @@ An example config of all authentication methods `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. !!! note "OAuth allowlist" - `allowed_oauth_emails` lists non-admin OAuth users who may sign in. The allowlist is `admin_emails` ∪ `allowed_oauth_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked. + `allowed_oauth_emails` lists non-admin OAuth users who may sign in. The allowlist is `admin_emails` ∪ `allowed_oauth_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked unless `allow_all_oauth_users` is true (open sign-in; trusted environments only). !!! note "OAuth redirect URL" diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md index 5d910cc..6fbef43 100644 --- a/docs/authentication/setup.md +++ b/docs/authentication/setup.md @@ -114,10 +114,12 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. |----------|-----------|---------| | `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails | | `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth non-admin allowed emails | + | `OAUTH_ALLOW_ALL_USERS` | `users.allow_all_oauth_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in | ```bash export ADMIN_EMAILS="admin@example.com,other@example.com" export ALLOWED_OAUTH_EMAILS="user@example.com" + # export OAUTH_ALLOW_ALL_USERS=true # optional: open OAuth sign-in (trusted env only) ``` Local admins must use `is_admin: true` in config; no env override. diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index b5eb5b8..c0b49d8 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -53,7 +53,8 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j | Key | Type | Description | |-----|------|-------------| | `admin_emails` | []string | OAuth emails with admin access; also counted as allowed to sign in | -| `allowed_oauth_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected | +| `allowed_oauth_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected unless `allow_all_oauth_users` is true | +| `allow_all_oauth_users` | bool | Default `false`. If `true`, skip email allowlist for OAuth sign-in (any OAuth user with an email may sign in). `admin_emails` still controls admin access only | | `local_users` | []object | `{username, password, is_admin}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | | `default_admin` | object | `{username, password}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | @@ -86,6 +87,7 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j users: admin_emails: [] allowed_oauth_emails: [] + allow_all_oauth_users: false local_users: [] default_admin: username: admin @@ -120,6 +122,7 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j "users": { "admin_emails": [], "allowed_oauth_emails": [], + "allow_all_oauth_users": false, "local_users": [] } } diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index c653410..3d3e8f5 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -40,6 +40,7 @@ Environment variables override config file values. Useful for secrets and deploy |----------|-----------|-------------| | `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails (admin + may sign in) | | `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth emails allowed to sign in without admin | +| `OAUTH_ALLOW_ALL_USERS` | `users.allow_all_oauth_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in (ignores email lists for sign-in) | ## Startup logging diff --git a/docs/configuration/index.md b/docs/configuration/index.md index e3af8f3..b6fd670 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -111,4 +111,4 @@ Configuration is loaded in this order: **environment variables** override **conf - **Server** — `root_path`, `max_upload_bytes`, `debug` - **Auth** — `jwt_secret`, `oauth_redirect_url`, `local_auth_enabled`, `providers` (google, github) - **Frontend** — `url`, `cors_origins` -- **Users** — `admin_emails`, `allowed_oauth_emails`, `local_users`, `default_admin` +- **Users** — `admin_emails`, `allowed_oauth_emails`, `allow_all_oauth_users`, `local_users`, `default_admin` diff --git a/docs/features/secure-auth.md b/docs/features/secure-auth.md index 0aa9030..4f81444 100644 --- a/docs/features/secure-auth.md +++ b/docs/features/secure-auth.md @@ -25,7 +25,7 @@ You can enable multiple providers; users choose which one to use at login. ## OAuth allowlist -Only addresses in **`admin_emails`** ∪ **`allowed_oauth_emails`** can complete OAuth sign-in. `admin_emails` grants **admin** access; `allowed_oauth_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. +Only addresses in **`admin_emails`** ∪ **`allowed_oauth_emails`** can complete OAuth sign-in, unless **`allow_all_oauth_users`** is enabled (any OAuth user with an email may sign in; use only in trusted environments). `admin_emails` grants **admin** access; `allowed_oauth_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty and allow-all is off, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. ## Admin access From 22f4e82d312e07ab75245f9a12ea23d35ef8d65c Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 10:56:12 +0100 Subject: [PATCH 05/11] refactor(config): rename OAuth user keys to oauth_* prefix - users: oauth_admin_emails, oauth_allowed_emails, oauth_allow_all_users - env: OAUTH_ADMIN_EMAILS, OAUTH_ALLOWED_EMAILS, OAUTH_ALLOW_ALL_USERS - Update API, Settings UI, examples, and docs; add CHANGELOG for v0.0.3-beta --- CHANGELOG.md | 36 ++++++++++++ app/config/config.go | 64 ++++++++++----------- app/config/oauth_allowlist_test.go | 16 +++--- app/config/registry.go | 6 +- app/handlers/auth.go | 6 +- app/handlers/config.go | 18 +++--- app/main.go | 8 +-- app/web/src/components/Settings.tsx | 34 +++++------ config.example.json | 6 +- config.example.yaml | 6 +- docs/authentication/index.md | 16 +++--- docs/authentication/setup.md | 18 +++--- docs/configuration/config-file.md | 18 +++--- docs/configuration/environment-variables.md | 6 +- docs/configuration/index.md | 8 +-- docs/features/secure-auth.md | 4 +- docs/features/simple-admin.md | 2 +- 17 files changed, 154 insertions(+), 118 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a020842 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/app/config/config.go b/app/config/config.go index 2ce1a24..14101d9 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -70,11 +70,11 @@ type FrontendConfig struct { } type UsersConfig struct { - AdminEmails []string `yaml:"admin_emails" json:"admin_emails"` - AllowedOAuthEmails []string `yaml:"allowed_oauth_emails" json:"allowed_oauth_emails"` // non-admin OAuth users allowed to sign in (union with admin_emails) - AllowAllOAuthUsers bool `yaml:"allow_all_oauth_users" json:"allow_all_oauth_users"` // if true, skip email allowlist for OAuth sign-in (admin_emails still gates admin) - 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 { @@ -153,14 +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.AllowedOAuthEmails) > 0 { - src.set("users.allowed_oauth_emails", SourceConfig) + if len(c.Users.OauthAllowedEmails) > 0 { + src.set("users.oauth_allowed_emails", SourceConfig) } - if c.Users.AllowAllOAuthUsers { - src.set("users.allow_all_oauth_users", 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 != "" { @@ -241,25 +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.AdminEmails = parts - src.set("users.admin_emails", SourceEnv) + c.Users.OauthAdminEmails = parts + src.set("users.oauth_admin_emails", SourceEnv) } - if v := os.Getenv("ALLOWED_OAUTH_EMAILS"); v != "" { + if v := os.Getenv("OAUTH_ALLOWED_EMAILS"); v != "" { parts := strings.Split(v, ",") for i, p := range parts { parts[i] = strings.TrimSpace(p) } - c.Users.AllowedOAuthEmails = parts - src.set("users.allowed_oauth_emails", SourceEnv) + c.Users.OauthAllowedEmails = parts + src.set("users.oauth_allowed_emails", SourceEnv) } if v := os.Getenv("OAUTH_ALLOW_ALL_USERS"); v != "" { - c.Users.AllowAllOAuthUsers = parseBoolEnv(v) - src.set("users.allow_all_oauth_users", SourceEnv) + c.Users.OauthAllowAllUsers = parseBoolEnv(v) + src.set("users.oauth_allow_all_users", SourceEnv) } // 3. Defaults @@ -285,14 +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.allowed_oauth_emails"] == 0 { - src.set("users.allowed_oauth_emails", SourceDefault) + if src["users.oauth_allowed_emails"] == 0 { + src.set("users.oauth_allowed_emails", SourceDefault) } - if src["users.allow_all_oauth_users"] == 0 { - src.set("users.allow_all_oauth_users", 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 @@ -436,9 +436,9 @@ 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.allowed_oauth_emails", fmt.Sprintf("%v", c.Users.AllowedOAuthEmails), src["users.allowed_oauth_emails"]}, - {"users.allow_all_oauth_users", fmt.Sprintf("%v", c.Users.AllowAllOAuthUsers), src["users.allow_all_oauth_users"]}, + {"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 { @@ -461,20 +461,20 @@ func OAuthProviderActive(c *Config) bool { } // OAuthLoginAllowlistConfigured returns true if OAuth sign-in is allowed without empty-list denial: -// allow_all_oauth_users, or at least one non-empty email in admin_emails or allowed_oauth_emails. +// 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.AllowAllOAuthUsers { + if c.Users.OauthAllowAllUsers { return true } - for _, e := range c.Users.AdminEmails { + for _, e := range c.Users.OauthAdminEmails { if strings.TrimSpace(e) != "" { return true } } - for _, e := range c.Users.AllowedOAuthEmails { + for _, e := range c.Users.OauthAllowedEmails { if strings.TrimSpace(e) != "" { return true } diff --git a/app/config/oauth_allowlist_test.go b/app/config/oauth_allowlist_test.go index 1a779cd..a8eba34 100644 --- a/app/config/oauth_allowlist_test.go +++ b/app/config/oauth_allowlist_test.go @@ -19,22 +19,22 @@ func TestOAuthLoginAllowlistConfigured(t *testing.T) { if OAuthLoginAllowlistConfigured(nil) { t.Fatal("nil config") } - c := &Config{Users: UsersConfig{AdminEmails: []string{" "}}} + c := &Config{Users: UsersConfig{OauthAdminEmails: []string{" "}}} if OAuthLoginAllowlistConfigured(c) { t.Fatal("whitespace only should not count") } - c.Users.AdminEmails = []string{"a@b.c"} + c.Users.OauthAdminEmails = []string{"a@b.c"} if !OAuthLoginAllowlistConfigured(c) { t.Fatal("admin email should count") } - c.Users.AdminEmails = nil - c.Users.AllowedOAuthEmails = []string{"u@x.y"} + c.Users.OauthAdminEmails = nil + c.Users.OauthAllowedEmails = []string{"u@x.y"} if !OAuthLoginAllowlistConfigured(c) { - t.Fatal("allowed_oauth_emails should count") + t.Fatal("oauth_allowed_emails should count") } - c.Users.AllowedOAuthEmails = nil - c.Users.AllowAllOAuthUsers = true + c.Users.OauthAllowedEmails = nil + c.Users.OauthAllowAllUsers = true if !OAuthLoginAllowlistConfigured(c) { - t.Fatal("allow_all_oauth_users should satisfy allowlist check") + t.Fatal("oauth_allow_all_users should satisfy allowlist check") } } diff --git a/app/config/registry.go b/app/config/registry.go index d138571..bdbafba 100644 --- a/app/config/registry.go +++ b/app/config/registry.go @@ -68,9 +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, Extra: "OAuth accounts with admin access. Also used as allowed sign-in emails (admins can always sign in)."}, - {Section: "users", Key: "allowed_oauth_emails", Kind: FieldStringSlice, Label: "Allowed OAuth emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with admin emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed (unless Allow all OAuth users is on)."}, - {Section: "users", Key: "allow_all_oauth_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "If enabled, any OAuth user with an email may sign in; email lists are ignored for sign-in. Use only in trusted environments. admin_emails still grants admin access only."}, + {Section: "users", Key: "oauth_admin_emails", Kind: FieldStringSlice, Label: "OAuth admin emails", Editable: true, Extra: "OAuth accounts with admin access. Also used as allowed sign-in emails (admins can always sign in)."}, + {Section: "users", Key: "oauth_allowed_emails", Kind: FieldStringSlice, Label: "OAuth allowed emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with oauth_admin_emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed (unless Allow all OAuth users is on)."}, + {Section: "users", Key: "oauth_allow_all_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "If enabled, any OAuth user with an email may sign in; email lists are ignored for sign-in. Use only in trusted environments. oauth_admin_emails still grants admin access 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"}, diff --git a/app/handlers/auth.go b/app/handlers/auth.go index 1302ebc..b331ce8 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -299,8 +299,8 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { c.Redirect(http.StatusFound, h.redirectTo("/login?error=userinfo")) return } - if !appCfg.Users.AllowAllOAuthUsers { - allow := oauthEmailAllowSet(appCfg.Users.AdminEmails, appCfg.Users.AllowedOAuthEmails) + if !appCfg.Users.OauthAllowAllUsers { + allow := oauthEmailAllowSet(appCfg.Users.OauthAdminEmails, appCfg.Users.OauthAllowedEmails) if len(allow) == 0 { log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_no_allowlist_configured", provider, email, name) c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) @@ -450,7 +450,7 @@ func (h *AuthHandler) Me(c *gin.Context) { picture, _ := c.Get("user_picture") emailStr, _ := email.(string) isAdmin := false - for _, e := range cfg.Users.AdminEmails { + for _, e := range cfg.Users.OauthAdminEmails { if strings.EqualFold(strings.TrimSpace(e), emailStr) { isAdmin = true break diff --git a/app/handlers/config.go b/app/handlers/config.go index 5593ee4..be99382 100644 --- a/app/handlers/config.go +++ b/app/handlers/config.go @@ -170,9 +170,9 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { }, "auth_providers": authProviders, "users": { - "admin_emails": cfg.Users.AdminEmails, - "allowed_oauth_emails": cfg.Users.AllowedOAuthEmails, - "allow_all_oauth_users": cfg.Users.AllowAllOAuthUsers, + "oauth_admin_emails": cfg.Users.OauthAdminEmails, + "oauth_allowed_emails": cfg.Users.OauthAllowedEmails, + "oauth_allow_all_users": cfg.Users.OauthAllowAllUsers, "local_users": localUsersUI, "default_admin_username": defaultAdminUsername, "default_admin_password": defaultAdminPasswordSet, // true = Set, false = Not set @@ -273,7 +273,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } } if s, ok := req["users"].(map[string]interface{}); ok { - if v, ok := s["admin_emails"]; ok { + if v, ok := s["oauth_admin_emails"]; ok { if arr, ok := toStringSlice(v); ok { emails := make([]string, 0, len(arr)) for _, e := range arr { @@ -282,10 +282,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { emails = append(emails, es) } } - cfg.Users.AdminEmails = emails + cfg.Users.OauthAdminEmails = emails } } - if v, ok := s["allowed_oauth_emails"]; ok { + if v, ok := s["oauth_allowed_emails"]; ok { if arr, ok := toStringSlice(v); ok { emails := make([]string, 0, len(arr)) for _, e := range arr { @@ -294,11 +294,11 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { emails = append(emails, es) } } - cfg.Users.AllowedOAuthEmails = emails + cfg.Users.OauthAllowedEmails = emails } } - if v, ok := s["allow_all_oauth_users"].(bool); ok { - cfg.Users.AllowAllOAuthUsers = v + if v, ok := s["oauth_allow_all_users"].(bool); ok { + cfg.Users.OauthAllowAllUsers = v } if v, ok := s["local_users"]; ok { if arr, ok := toLocalUsers(v, cfg.Users.LocalUsers); ok { diff --git a/app/main.go b/app/main.go index 3279a46..91d0c00 100644 --- a/app/main.go +++ b/app/main.go @@ -21,10 +21,10 @@ func main() { } if config.OAuthProviderActive(cfg) && !config.OAuthLoginAllowlistConfigured(cfg) { - log.Println("[config] OAuth provider(s) are enabled but users.admin_emails and users.allowed_oauth_emails are empty — OAuth sign-in will be denied until at least one email is listed (or set users.allow_all_oauth_users).") + log.Println("[config] OAuth provider(s) are enabled but users.oauth_admin_emails and users.oauth_allowed_emails are empty — OAuth sign-in will be denied until at least one email is listed (or set users.oauth_allow_all_users).") } - if config.OAuthProviderActive(cfg) && cfg.Users.AllowAllOAuthUsers { - log.Println("[config] users.allow_all_oauth_users is enabled — any OAuth user with a verified email may sign in; admin_emails still controls admin access only.") + if config.OAuthProviderActive(cfg) && cfg.Users.OauthAllowAllUsers { + log.Println("[config] users.oauth_allow_all_users is enabled — any OAuth user with a verified email may sign in; oauth_admin_emails still controls admin access only.") } if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil { @@ -68,7 +68,7 @@ func main() { if cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "" { localAdminUsernames = append(localAdminUsernames, cfg.Users.DefaultAdmin.Username) } - configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(cfg.Users.AdminEmails, localAdminUsernames)) + configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(cfg.Users.OauthAdminEmails, localAdminUsernames)) configGroup.GET("", configH.GetConfig) configGroup.PATCH("", configH.UpdateConfig) diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index bc5cfa8..caf6a95 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -96,7 +96,7 @@ function buildFormValues(res: ConfigAPIResponse): Record { const field = res.schema.fields?.find((f) => f.section === section && f.key === key) if (field?.kind === 'bytes' && typeof val === 'number') { sectionOut[key] = bytesToHuman(val) - } else if (field?.kind === 'string[]' && (key === 'admin_emails' || key === 'allowed_oauth_emails')) { + } else if (field?.kind === 'string[]' && (key === 'oauth_admin_emails' || key === 'oauth_allowed_emails')) { const arr = Array.isArray(val) ? (val as string[]) : [] sectionOut[key] = arr.length ? arr : [''] } else if (key === 'default_admin_password') { @@ -141,9 +141,9 @@ function categoryForField(name: (string | number)[]): { if (first === 'users') { const key = name[1] if ( - key === 'admin_emails' || - key === 'allowed_oauth_emails' || - key === 'allow_all_oauth_users' || + key === 'oauth_admin_emails' || + key === 'oauth_allowed_emails' || + key === 'oauth_allow_all_users' || key === 'default_admin_username' || key === 'default_admin_password' ) { @@ -172,9 +172,9 @@ export default function Settings() { const authProviders = Form.useWatch(['auth_providers'], form) ?? (config?.values as Record>)?.auth_providers const localUsersFormValues = (Form.useWatch(['users', 'local_users'], form) ?? []) as Array> - const adminEmailsWatch = Form.useWatch(['users', 'admin_emails'], form) - const allowedOAuthWatch = Form.useWatch(['users', 'allowed_oauth_emails'], form) - const allowAllOAuthWatch = Form.useWatch(['users', 'allow_all_oauth_users'], form) + const oauthAdminEmailsWatch = Form.useWatch(['users', 'oauth_admin_emails'], form) + const oauthAllowedEmailsWatch = Form.useWatch(['users', 'oauth_allowed_emails'], form) + const oauthAllowAllUsersWatch = Form.useWatch(['users', 'oauth_allow_all_users'], form) const oauthEnabled = (authProviders && typeof authProviders === 'object' && ((authProviders as Record>).google?.enabled === true || @@ -182,11 +182,11 @@ export default function Settings() { false const oauthSignInOk = useMemo(() => { - if (allowAllOAuthWatch === true) return true + if (oauthAllowAllUsersWatch === true) return true const countNonEmpty = (arr: unknown) => Array.isArray(arr) ? (arr as unknown[]).filter((e) => typeof e === 'string' && e.trim()).length : 0 - return countNonEmpty(adminEmailsWatch) + countNonEmpty(allowedOAuthWatch) > 0 - }, [allowAllOAuthWatch, adminEmailsWatch, allowedOAuthWatch]) + return countNonEmpty(oauthAdminEmailsWatch) + countNonEmpty(oauthAllowedEmailsWatch) > 0 + }, [oauthAllowAllUsersWatch, oauthAdminEmailsWatch, oauthAllowedEmailsWatch]) useEffect(() => { getConfig() @@ -309,18 +309,18 @@ export default function Settings() { return users .filter((f) => [ - 'admin_emails', - 'allowed_oauth_emails', - 'allow_all_oauth_users', + 'oauth_admin_emails', + 'oauth_allowed_emails', + 'oauth_allow_all_users', 'default_admin_username', 'default_admin_password', ].includes(f.key), ) .sort((a, b) => { const order = [ - 'admin_emails', - 'allowed_oauth_emails', - 'allow_all_oauth_users', + 'oauth_admin_emails', + 'oauth_allowed_emails', + 'oauth_allow_all_users', 'default_admin_username', 'default_admin_password', ] @@ -647,7 +647,7 @@ const ConfigField = React.memo(function ConfigField({ if (field.kind === 'string[]') { const arr = Array.isArray(rawValue) ? rawValue : [] const list = arr.filter((x): x is string => typeof x === 'string') - const isOAuthEmailList = field.key === 'admin_emails' || field.key === 'allowed_oauth_emails' + const isOAuthEmailList = field.key === 'oauth_admin_emails' || field.key === 'oauth_allowed_emails' const oauthListDisabled = isOAuthEmailList && !oauthEnabled const initialList = isOAuthEmailList && list.length === 0 ? [''] : list const labelInRow = addButtonPosition === 'right' diff --git a/config.example.json b/config.example.json index 455460a..0cf5076 100644 --- a/config.example.json +++ b/config.example.json @@ -21,9 +21,9 @@ "url": "http://localhost:5173" }, "users": { - "admin_emails": [], - "allowed_oauth_emails": [], - "allow_all_oauth_users": false, + "oauth_admin_emails": [], + "oauth_allowed_emails": [], + "oauth_allow_all_users": false, "local_users": [], "default_admin": null } diff --git a/config.example.yaml b/config.example.yaml index a6299b5..abc83b3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,9 +26,9 @@ frontend: url: http://localhost:5173 users: - admin_emails: [] # OAuth emails: admin + allowed to sign in - allowed_oauth_emails: [] # OAuth emails: non-admin users allowed to sign in (union with admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied unless allow_all_oauth_users is true. - allow_all_oauth_users: false # If true, any OAuth user with an email may sign in (lists ignored for sign-in). Trusted environments only. + oauth_admin_emails: [] # OAuth emails: admin + allowed to sign in + oauth_allowed_emails: [] # OAuth emails: non-admin users allowed to sign in (union with oauth_admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied unless oauth_allow_all_users is true. + oauth_allow_all_users: false # If true, any OAuth user with an email may sign in (lists ignored for sign-in). Trusted environments only. local_users: [] # Local username/password users # default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users) # default_admin: diff --git a/docs/authentication/index.md b/docs/authentication/index.md index 3602129..79134cf 100644 --- a/docs/authentication/index.md +++ b/docs/authentication/index.md @@ -78,7 +78,7 @@ Setup Google as OAuth provider client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] ``` === "JSON" @@ -96,7 +96,7 @@ Setup Google as OAuth provider } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ] } @@ -119,7 +119,7 @@ Setup Google as OAuth provider client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] ``` === "JSON" ```json title="config.json" @@ -136,7 +136,7 @@ Setup Google as OAuth provider } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ] } @@ -163,7 +163,7 @@ An example config of all authentication methods client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] local_users: - username: bob password: changeme # plaintext hashed on first run, or use bcrypt hash @@ -193,7 +193,7 @@ An example config of all authentication methods } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ], "local_users": [ @@ -215,10 +215,10 @@ An example config of all authentication methods `default_admin` and `local_users` use a single `password` field. Plaintext is hashed on first startup and replaced in-place. You can also provide a bcrypt hash directly. !!! note "OAuth admins" - `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. + `oauth_admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. !!! note "OAuth allowlist" - `allowed_oauth_emails` lists non-admin OAuth users who may sign in. The allowlist is `admin_emails` ∪ `allowed_oauth_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked unless `allow_all_oauth_users` is true (open sign-in; trusted environments only). + `oauth_allowed_emails` lists non-admin OAuth users who may sign in. The allowlist is `oauth_admin_emails` ∪ `oauth_allowed_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked unless `oauth_allow_all_users` is true (open sign-in; trusted environments only). !!! note "OAuth redirect URL" diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md index 6fbef43..bdd5853 100644 --- a/docs/authentication/setup.md +++ b/docs/authentication/setup.md @@ -80,10 +80,10 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. ## Admin users !!! note "OAuth admins only" - `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, use `is_admin: true` in `local_users`. + `oauth_admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, use `is_admin: true` in `local_users`. !!! note "OAuth who may sign in" - `allowed_oauth_emails` lists OAuth users who may sign in **without** admin. Together with `admin_emails`, they form the OAuth allowlist. If OAuth is enabled and both lists are empty, no OAuth sign-in is allowed. + `oauth_allowed_emails` lists OAuth users who may sign in **without** admin. Together with `oauth_admin_emails`, they form the OAuth allowlist. If OAuth is enabled and both lists are empty, no OAuth sign-in is allowed. === "Config file" @@ -91,10 +91,10 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. ```yaml users: - admin_emails: + oauth_admin_emails: - admin@example.com - other-admin@example.com - allowed_oauth_emails: + oauth_allowed_emails: - user@example.com ``` @@ -112,13 +112,13 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. | Variable | Overrides | Purpose | |----------|-----------|---------| - | `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails | - | `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth non-admin allowed emails | - | `OAUTH_ALLOW_ALL_USERS` | `users.allow_all_oauth_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in | + | `OAUTH_ADMIN_EMAILS` | `users.oauth_admin_emails` | Comma-separated OAuth admin emails | + | `OAUTH_ALLOWED_EMAILS` | `users.oauth_allowed_emails` | Comma-separated OAuth non-admin allowed emails | + | `OAUTH_ALLOW_ALL_USERS` | `users.oauth_allow_all_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in | ```bash - export ADMIN_EMAILS="admin@example.com,other@example.com" - export ALLOWED_OAUTH_EMAILS="user@example.com" + export OAUTH_ADMIN_EMAILS="admin@example.com,other@example.com" + export OAUTH_ALLOWED_EMAILS="user@example.com" # export OAUTH_ALLOW_ALL_USERS=true # optional: open OAuth sign-in (trusted env only) ``` diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index c0b49d8..fabb941 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -52,9 +52,9 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j | Key | Type | Description | |-----|------|-------------| -| `admin_emails` | []string | OAuth emails with admin access; also counted as allowed to sign in | -| `allowed_oauth_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected unless `allow_all_oauth_users` is true | -| `allow_all_oauth_users` | bool | Default `false`. If `true`, skip email allowlist for OAuth sign-in (any OAuth user with an email may sign in). `admin_emails` still controls admin access only | +| `oauth_admin_emails` | []string | OAuth emails with admin access; also counted as allowed to sign in | +| `oauth_allowed_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `oauth_admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected unless `oauth_allow_all_users` is true | +| `oauth_allow_all_users` | bool | Default `false`. If `true`, skip email allowlist for OAuth sign-in (any OAuth user with an email may sign in). `oauth_admin_emails` still controls admin access only | | `local_users` | []object | `{username, password, is_admin}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | | `default_admin` | object | `{username, password}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | @@ -85,9 +85,9 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j url: http://localhost:5173 users: - admin_emails: [] - allowed_oauth_emails: [] - allow_all_oauth_users: false + oauth_admin_emails: [] + oauth_allowed_emails: [] + oauth_allow_all_users: false local_users: [] default_admin: username: admin @@ -120,9 +120,9 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j "url": "http://localhost:5173" }, "users": { - "admin_emails": [], - "allowed_oauth_emails": [], - "allow_all_oauth_users": false, + "oauth_admin_emails": [], + "oauth_allowed_emails": [], + "oauth_allow_all_users": false, "local_users": [] } } diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 3d3e8f5..580db9b 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -38,9 +38,9 @@ Environment variables override config file values. Useful for secrets and deploy | Variable | Overrides | Description | |----------|-----------|-------------| -| `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails (admin + may sign in) | -| `ALLOWED_OAUTH_EMAILS` | `users.allowed_oauth_emails` | Comma-separated OAuth emails allowed to sign in without admin | -| `OAUTH_ALLOW_ALL_USERS` | `users.allow_all_oauth_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in (ignores email lists for sign-in) | +| `OAUTH_ADMIN_EMAILS` | `users.oauth_admin_emails` | Comma-separated OAuth admin emails (admin + may sign in) | +| `OAUTH_ALLOWED_EMAILS` | `users.oauth_allowed_emails` | Comma-separated OAuth emails allowed to sign in without admin | +| `OAUTH_ALLOW_ALL_USERS` | `users.oauth_allow_all_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in (ignores email lists for sign-in) | ## Startup logging diff --git a/docs/configuration/index.md b/docs/configuration/index.md index b6fd670..3e0d4de 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -36,7 +36,7 @@ Configuration is loaded in this order: **environment variables** override **conf url: http://example.com users: - admin_emails: [] + oauth_admin_emails: [] local_users: [] ``` @@ -68,7 +68,7 @@ Configuration is loaded in this order: **environment variables** override **conf "url": "http://example.com" }, "users": { - "admin_emails": [], + "oauth_admin_emails": [], "local_users": [] } } @@ -94,7 +94,7 @@ Configuration is loaded in this order: **environment variables** override **conf export FRONTEND_URL="http://example.com" export CORS_ORIGINS="http://localhost:5173" - export ADMIN_EMAILS="admin@example.com" + export OAUTH_ADMIN_EMAILS="admin@example.com" ``` See [Environment variables](environment-variables.md) for the full reference. @@ -111,4 +111,4 @@ Configuration is loaded in this order: **environment variables** override **conf - **Server** — `root_path`, `max_upload_bytes`, `debug` - **Auth** — `jwt_secret`, `oauth_redirect_url`, `local_auth_enabled`, `providers` (google, github) - **Frontend** — `url`, `cors_origins` -- **Users** — `admin_emails`, `allowed_oauth_emails`, `allow_all_oauth_users`, `local_users`, `default_admin` +- **Users** — `oauth_admin_emails`, `oauth_allowed_emails`, `oauth_allow_all_users`, `local_users`, `default_admin` diff --git a/docs/features/secure-auth.md b/docs/features/secure-auth.md index 4f81444..65044aa 100644 --- a/docs/features/secure-auth.md +++ b/docs/features/secure-auth.md @@ -25,8 +25,8 @@ You can enable multiple providers; users choose which one to use at login. ## OAuth allowlist -Only addresses in **`admin_emails`** ∪ **`allowed_oauth_emails`** can complete OAuth sign-in, unless **`allow_all_oauth_users`** is enabled (any OAuth user with an email may sign in; use only in trusted environments). `admin_emails` grants **admin** access; `allowed_oauth_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty and allow-all is off, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. +Only addresses in **`oauth_admin_emails`** ∪ **`oauth_allowed_emails`** can complete OAuth sign-in, unless **`oauth_allow_all_users`** is enabled (any OAuth user with an email may sign in; use only in trusted environments). `oauth_admin_emails` grants **admin** access; `oauth_allowed_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty and allow-all is off, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. ## Admin access -Users listed in `admin_emails` (for OAuth) or with `is_admin: true` (for local users) can access the admin UI to manage auth providers and local users. A `default_admin` user can be bootstrapped on first run when no users exist. +Users listed in `oauth_admin_emails` (for OAuth) or with `is_admin: true` (for local users) can access the admin UI to manage auth providers and local users. A `default_admin` user can be bootstrapped on first run when no users exist. diff --git a/docs/features/simple-admin.md b/docs/features/simple-admin.md index e4394bb..f402762 100644 --- a/docs/features/simple-admin.md +++ b/docs/features/simple-admin.md @@ -15,7 +15,7 @@ No database — configuration is stored in a single YAML or JSON file. The admin ## Admin UI -Admins (users in `admin_emails` or with `is_admin`) can access the Settings page to: +Admins (users in `oauth_admin_emails` or with `is_admin`) can access the Settings page to: - **Auth providers** — Enable/disable Google and GitHub OAuth, set client ID and secret - **Local users** — Add, edit, remove username/password users From 064c8a1b4beb7504a68f651e3d999b989ea68b36 Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 11:13:51 +0100 Subject: [PATCH 06/11] feat(settings): improve OAuth admin user UX - Shorter registry help text and clearer field labels - Section headings: OAuth sign-in vs default admin bootstrap - Warning when allow-all is on; confirm modal before enabling - Placeholders: admin@ vs user@ for the two email lists --- app/config/registry.go | 6 +- app/web/src/components/Settings.tsx | 116 ++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/app/config/registry.go b/app/config/registry.go index bdbafba..99895ac 100644 --- a/app/config/registry.go +++ b/app/config/registry.go @@ -68,9 +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: "oauth_admin_emails", Kind: FieldStringSlice, Label: "OAuth admin emails", Editable: true, Extra: "OAuth accounts with admin access. Also used as allowed sign-in emails (admins can always sign in)."}, - {Section: "users", Key: "oauth_allowed_emails", Kind: FieldStringSlice, Label: "OAuth allowed emails (non-admin)", Editable: true, Extra: "OAuth accounts that may sign in without admin. Union with oauth_admin_emails; if OAuth is enabled and both lists are empty, no OAuth sign-in is allowed (unless Allow all OAuth users is on)."}, - {Section: "users", Key: "oauth_allow_all_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "If enabled, any OAuth user with an email may sign in; email lists are ignored for sign-in. Use only in trusted environments. oauth_admin_emails still grants admin access only."}, + {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"}, diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index caf6a95..1c44bed 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -17,6 +17,8 @@ import { Row, Col, Alert, + Divider, + Modal, } from 'antd' import { CloudServerOutlined, @@ -304,28 +306,20 @@ export default function Settings() { } }, [fieldsBySection.auth]) - const adminUserFields = useMemo(() => { + const oauthAdminUserFields = useMemo(() => { const users = fieldsBySection.users ?? [] + const order = ['oauth_admin_emails', 'oauth_allowed_emails', 'oauth_allow_all_users'] return users - .filter((f) => - [ - 'oauth_admin_emails', - 'oauth_allowed_emails', - 'oauth_allow_all_users', - 'default_admin_username', - 'default_admin_password', - ].includes(f.key), - ) - .sort((a, b) => { - const order = [ - 'oauth_admin_emails', - 'oauth_allowed_emails', - 'oauth_allow_all_users', - 'default_admin_username', - 'default_admin_password', - ] - return order.indexOf(a.key) - order.indexOf(b.key) - }) + .filter((f) => order.includes(f.key)) + .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) + }, [fieldsBySection.users]) + + const defaultAdminBootstrapFields = useMemo(() => { + const users = fieldsBySection.users ?? [] + const order = ['default_admin_username', 'default_admin_password'] + return users + .filter((f) => order.includes(f.key)) + .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) }, [fieldsBySection.users]) const localUsersFields = useMemo( @@ -439,11 +433,44 @@ export default function Settings() { type="warning" showIcon style={{ marginBottom: 16 }} - message="OAuth sign-in disabled for everyone" - description="Add at least one email under Admin or Allowed OAuth emails, or enable Allow all OAuth users. Otherwise OAuth logins are rejected." + message="OAuth sign-in is turned off for everyone" + description="Add at least one email under Admins or Additional sign-ins, or enable Allow all OAuth users. Otherwise OAuth logins are rejected." /> )} - {adminUserFields.map((field) => ( + + OAuth sign-in + + + Who may use Google or GitHub to sign in. If both lists are empty and allow-all is off, OAuth login is blocked. + + {oauthAdminUserFields.map((field) => ( + + ))} + {oauthAllowAllUsersWatch === true && ( + + )} + + + Default admin (bootstrap) + + + Used when no local users exist yet. Password is stored hashed in config after first startup. + + {defaultAdminBootstrapFields.map((field) => ( void }) { + return ( + { + if (checked) { + Modal.confirm({ + title: 'Allow any OAuth user to sign in?', + content: + 'Email lists will not restrict who can sign in with OAuth. Admin access still follows the admin list only. Use only in trusted environments.', + okText: 'Enable', + okType: 'danger', + cancelText: 'Cancel', + onOk: () => onChange?.(true), + }) + } else { + onChange?.(false) + } + }} + /> + ) +} + const ProviderSection = React.memo(function ProviderSection({ providerSection, values, @@ -637,6 +691,19 @@ const ConfigField = React.memo(function ConfigField({ const namePath = [sectionId, field.key] if (field.kind === 'bool') { + if (field.key === 'oauth_allow_all_users') { + return ( + {field.extra} : undefined} + style={{ marginBottom: 12 }} + > + + + ) + } return ( @@ -650,6 +717,7 @@ const ConfigField = React.memo(function ConfigField({ const isOAuthEmailList = field.key === 'oauth_admin_emails' || field.key === 'oauth_allowed_emails' const oauthListDisabled = isOAuthEmailList && !oauthEnabled const initialList = isOAuthEmailList && list.length === 0 ? [''] : list + const oauthPlaceholder = field.key === 'oauth_admin_emails' ? 'admin@example.com' : 'user@example.com' const labelInRow = addButtonPosition === 'right' return ( Date: Thu, 26 Mar 2026 11:50:49 +0100 Subject: [PATCH 07/11] refactor(settings): move default admin bootstrap under Local tab - OAuth sub-tab is OAuth-only; Local sub-tab has local auth, local users, divider, then default admin bootstrap - Rename sidebar labels to OAuth / Local; fix categoryForField for bootstrap fields --- app/web/src/components/Settings.tsx | 51 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index 1c44bed..8d2a6f7 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -74,8 +74,8 @@ const AUTH_PROVIDER_MENU_ITEMS = [ ] as const const USERS_MENU_ITEMS = [ - { key: 'admin_user', label: 'Admin user', icon: }, - { key: 'local_user', label: 'Local user', icon: }, + { key: 'admin_user', label: 'OAuth', icon: }, + { key: 'local_user', label: 'Local', icon: }, ] as const const SECTION_OPTIONS = TOP_LEVEL_SECTIONS.map((s) => ({ @@ -142,15 +142,12 @@ function categoryForField(name: (string | number)[]): { if (first === 'auth' || first === 'auth_providers') return { section: 'auth_providers', authProviderSub: 'google' } if (first === 'users') { const key = name[1] - if ( - key === 'oauth_admin_emails' || - key === 'oauth_allowed_emails' || - key === 'oauth_allow_all_users' || - key === 'default_admin_username' || - key === 'default_admin_password' - ) { + if (key === 'oauth_admin_emails' || key === 'oauth_allowed_emails' || key === 'oauth_allow_all_users') { return { section: 'users', usersSub: 'admin_user' } } + if (key === 'default_admin_username' || key === 'default_admin_password') { + return { section: 'users', usersSub: 'local_user' } + } return { section: 'users', usersSub: 'local_user' } } return { section: 'auth_providers', authProviderSub: 'google' } @@ -463,24 +460,6 @@ export default function Settings() { description="Any OAuth user with an email can sign in. Email lists are ignored for sign-in; only the admin list controls who gets admin access." /> )} - - - Default admin (bootstrap) - - - Used when no local users exist yet. Password is stored hashed in config after first startup. - - {defaultAdminBootstrapFields.map((field) => ( - - ))} )} {usersSub === 'local_user' && ( @@ -505,6 +484,24 @@ export default function Settings() { localUsersFormValues={localUsersFormValues} /> ))} + + + Default admin (bootstrap) + + + Used when no local users exist yet. Password is stored hashed in config after first startup. + + {defaultAdminBootstrapFields.map((field) => ( + + ))} )} From 6d82ff7158f6e051dfaa2dacf2f648b3e000c9ab Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 11:55:22 +0100 Subject: [PATCH 08/11] style(settings): horizontal form layout for label + control rows - Form layout horizontal with responsive labelCol/wrapperCol - colon=false, labelAlign=left on form - OAuth/local string[] and local_users list items use Form label column; Add actions only on the right - Local user card fields use layout=vertical to avoid cramped columns --- app/web/src/components/Settings.tsx | 42 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index 8d2a6f7..d6fec01 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -84,6 +84,12 @@ const SECTION_OPTIONS = TOP_LEVEL_SECTIONS.map((s) => ({ icon: SECTION_ICONS[s.id], })) +/** Settings form: label left, control right; stacks on narrow viewports. */ +const SETTINGS_FORM_LAYOUT = { + labelCol: { xs: 24, sm: 9, md: 8, lg: 7 }, + wrapperCol: { xs: 24, sm: 15, md: 16, lg: 17 }, +} + type AuthProviderSubSection = 'google' | 'github' type UsersSubSection = 'admin_user' | 'local_user' @@ -360,7 +366,10 @@ export default function Settings() {
> }) { - const { token } = theme.useToken() const sectionValues = values[sectionId] ?? {} const rawValue = sectionValues[field.key] @@ -718,7 +726,7 @@ const ConfigField = React.memo(function ConfigField({ const labelInRow = addButtonPosition === 'right' return ( Enable an OAuth provider (Google or GitHub) to configure OAuth email lists. @@ -732,8 +740,7 @@ const ConfigField = React.memo(function ConfigField({ {(fields, { add, remove }, { errors }) => ( <> {labelInRow && ( -
- {field.label} +
@@ -786,15 +793,15 @@ const ConfigField = React.memo(function ConfigField({ const addInLabelRow = addButtonPosition === 'right' return ( Enable "Local users enabled" above to add local users. : undefined} + style={{ marginBottom: 12 }} > {(fields, { add, remove }) => ( <> {addInLabelRow && ( -
- {field.label} +
+ } + styles={{ body: { paddingBlock: 16 } }} + > + {fields.map(({ key, name, ...restField }, index) => { + const item = listForPasswordFlag[name] ?? localUsersFormValues?.[name] ?? {} + const passwordSet = !!item?.password_set + return ( + + + + + + + + + + + + + + + + + + +
+ + ) +}) + const ConfigField = React.memo(function ConfigField({ sectionId, field, @@ -673,7 +820,7 @@ const ConfigField = React.memo(function ConfigField({ if (field.secret && !field.editable) { const isSet = !!rawValue return ( - {isSet ? 'Value is set.' : 'Not set.'}} style={{ marginBottom: 12 }}> + ) @@ -685,7 +832,7 @@ const ConfigField = React.memo(function ConfigField({ {isSet ? 'Value is set. Enter a new value to change it.' : 'Enter password for first-time setup.'}} + tooltip={formItemTooltip(isSet ? 'Value is set. Enter a new value to change it.' : 'Enter password for first-time setup.')} style={{ marginBottom: 12 }} > @@ -702,7 +849,7 @@ const ConfigField = React.memo(function ConfigField({ name={namePath} label={field.label} valuePropName="checked" - extra={field.extra ? {field.extra} : undefined} + tooltip={formItemTooltip(field.extra)} style={{ marginBottom: 12 }} > @@ -724,18 +871,11 @@ const ConfigField = React.memo(function ConfigField({ const initialList = isOAuthEmailList && list.length === 0 ? [''] : list const oauthPlaceholder = field.key === 'oauth_admin_emails' ? 'admin@example.com' : 'user@example.com' const labelInRow = addButtonPosition === 'right' + const oauthListTooltip = oauthListDisabled + ? 'Enable an OAuth provider (Google or GitHub) to configure OAuth email lists.' + : field.extra return ( - Enable an OAuth provider (Google or GitHub) to configure OAuth email lists. - ) : field.extra ? ( - {field.extra} - ) : undefined - } - style={{ marginBottom: 12 }} - > + {(fields, { add, remove }, { errors }) => ( <> @@ -785,113 +925,21 @@ const ConfigField = React.memo(function ConfigField({ if (field.kind === 'object[]' && field.key === 'local_users') { const arr = Array.isArray(rawValue) ? rawValue : [] const list = arr.map((u: Record) => ({ - username: u.username ?? '', + username: typeof u.username === 'string' ? u.username : '', password: '', - is_admin: u.is_admin ?? false, + is_admin: typeof u.is_admin === 'boolean' ? u.is_admin : false, password_set: !!u.password_set, })) - const addInLabelRow = addButtonPosition === 'right' return ( - Enable "Local users enabled" above to add local users. : undefined} - style={{ marginBottom: 12 }} - > - - {(fields, { add, remove }) => ( - <> - {addInLabelRow && ( -
- -
- )} - {fields.map(({ key, name, ...restField }) => { - const item = list[name] ?? localUsersFormValues?.[name] ?? {} - const passwordSet = !!item?.password_set - return ( - - - - - - - - - - Value is set. Enter a new value to change it. : undefined} - style={{ marginBottom: 0 }} - > - - - - - - - - - - - - - - - - - )})} - {!addInLabelRow && ( - -
- -
-
- )} - - )} -
-
+ tooltip={formItemTooltip(!localAuthEnabled ? 'Enable "Local users enabled" above to add local users.' : undefined)} + /> ) } @@ -900,7 +948,7 @@ const ConfigField = React.memo(function ConfigField({ {field.extra} : undefined} + tooltip={formItemTooltip(field.extra)} style={{ marginBottom: 12 }} rules={ isBytes From 17ad78c547a74bb148afc3471c63ef485c94d65d Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 14:20:22 +0100 Subject: [PATCH 10/11] fix: publish config atomically via LiveConfig snapshot. its not a feature of full config reload --- app/handlers/auth.go | 17 +++++++++-------- app/handlers/config.go | 33 ++++++++++++++++++--------------- app/main.go | 7 ++++--- app/middleware/admin.go | 6 +++--- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/handlers/auth.go b/app/handlers/auth.go index 4a315cf..abf492f 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -28,7 +28,7 @@ var ( type AuthHandler struct { oauth2Configs map[string]*oauth2.Config // provider id -> config jwtSecret []byte - appCfg *config.Config // live pointer; same as ConfigHandler (admin PATCH updates allowlists) + live *config.LiveConfig // atomic snapshots; same store as ConfigHandler } // oauthCallbackURL derives the callback URL for a provider from the base oauth_redirect_url. @@ -45,7 +45,8 @@ func oauthCallbackURL(baseRedirectURL, provider string) string { return baseRedirectURL[:idx] + "/api/auth/" + provider + "/callback" } -func NewAuthHandler(cfg *config.Config) *AuthHandler { +func NewAuthHandler(live *config.LiveConfig) *AuthHandler { + cfg := live.Snapshot() baseRedirectURL := strings.TrimSpace(cfg.Auth.OAuthRedirectURL) oauth2Configs := make(map[string]*oauth2.Config) if cfg.Auth.Providers != nil { @@ -85,7 +86,7 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler { return &AuthHandler{ oauth2Configs: oauth2Configs, jwtSecret: []byte(cfg.Auth.JWTSecret), - appCfg: cfg, + live: live, } } @@ -128,7 +129,7 @@ type LoginProviderInfo struct { // LoginOptions returns enabled auth methods (public, no JWT). func (h *AuthHandler) LoginOptions(c *gin.Context) { - cfg := h.appCfg + cfg := h.live.Snapshot() localUsers := cfg.Users.LocalUsers if localUsers == nil { localUsers = []config.LocalUser{} @@ -148,7 +149,7 @@ type LocalLoginRequest struct { // LocalLogin authenticates a local user or default admin and returns JWT. func (h *AuthHandler) LocalLogin(c *gin.Context) { - cfg := h.appCfg + cfg := h.live.Snapshot() localUsers := cfg.Users.LocalUsers if localUsers == nil { localUsers = []config.LocalUser{} @@ -292,7 +293,7 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { return } - appCfg := h.appCfg + appCfg := h.live.Snapshot() emailKey := strings.ToLower(strings.TrimSpace(email)) if emailKey == "" { log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_missing", provider, email, name) @@ -429,7 +430,7 @@ func (h *AuthHandler) fetchGitHubPrimaryEmail(_ context.Context, client *http.Cl } func (h *AuthHandler) redirectTo(path string) string { - base := h.appCfg.Frontend.URL + base := h.live.Snapshot().Frontend.URL if base == "" || base == "/" { return path } @@ -444,7 +445,7 @@ func (h *AuthHandler) redirectTo(path string) string { // Me returns the current user (requires auth middleware). func (h *AuthHandler) Me(c *gin.Context) { - cfg := h.appCfg + cfg := h.live.Snapshot() email, _ := c.Get("user_email") name, _ := c.Get("user_name") picture, _ := c.Get("user_picture") diff --git a/app/handlers/config.go b/app/handlers/config.go index be99382..c9088c2 100644 --- a/app/handlers/config.go +++ b/app/handlers/config.go @@ -16,12 +16,12 @@ import ( // ConfigHandler serves config API (admin only). type ConfigHandler struct { - cfg *config.Config + live *config.LiveConfig } // NewConfigHandler returns a ConfigHandler. -func NewConfigHandler(cfg *config.Config) *ConfigHandler { - return &ConfigHandler{cfg: cfg} +func NewConfigHandler(live *config.LiveConfig) *ConfigHandler { + return &ConfigHandler{live: live} } // ConfigSection is a section in the config schema. @@ -75,7 +75,7 @@ type ConfigAPIResponse struct { // GetConfig returns schema + current values (admin only). func (h *ConfigHandler) GetConfig(c *gin.Context) { - cfg := h.cfg + cfg := h.live.Snapshot() // Build schema from registry sections := []ConfigSection{ @@ -194,22 +194,23 @@ type ConfigUpdateRequest map[string]interface{} // UpdateConfig updates config file (admin only). Server restart required for most changes. func (h *ConfigHandler) UpdateConfig(c *gin.Context) { - if h.cfg.ConfigPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "config file not configured (CONFIG_FILE not set)"}) - return - } - var req ConfigUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - cfg := *h.cfg + cur := h.live.Snapshot() + if cur.ConfigPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "config file not configured (CONFIG_FILE not set)"}) + return + } + + cfg := *cur // Deep copy Providers map to avoid mutating live config before file write succeeds, // and to prevent concurrent map read/write if GetConfig runs during UpdateConfig. cfg.Auth.Providers = make(map[string]config.ProviderConfig) - for k, v := range h.cfg.Auth.Providers { + for k, v := range cur.Auth.Providers { cfg.Auth.Providers[k] = v } @@ -335,18 +336,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } // Write to file - data, err := marshalConfig(&cfg, h.cfg.ConfigPath) + data, err := marshalConfig(&cfg, cur.ConfigPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize config: " + err.Error()}) return } - if err := os.WriteFile(h.cfg.ConfigPath, data, 0600); err != nil { + if err := os.WriteFile(cur.ConfigPath, data, 0600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config: " + err.Error()}) return } - // Update in-memory config - *h.cfg = cfg + // Publish new config atomically (readers use Snapshot; no in-place struct copy) + pub := new(config.Config) + *pub = cfg + h.live.Replace(pub) c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Config saved. Server restart required for some changes."}) } diff --git a/app/main.go b/app/main.go index 4b91af3..db5d214 100644 --- a/app/main.go +++ b/app/main.go @@ -37,8 +37,9 @@ func main() { if err != nil { log.Fatalf("handlers: invalid root path: %v", err) } - authH := handlers.NewAuthHandler(cfg) - configH := handlers.NewConfigHandler(cfg) + live := config.NewLiveConfig(cfg) + authH := handlers.NewAuthHandler(live) + configH := handlers.NewConfigHandler(live) if !cfg.Server.Debug { gin.SetMode(gin.ReleaseMode) } @@ -59,7 +60,7 @@ func main() { api.GET("/auth/me", middleware.Auth(), authH.Me) // Admin-only config routes (RequireAdmin reads live cfg on each request) - configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(cfg)) + configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(live)) configGroup.GET("", configH.GetConfig) configGroup.PATCH("", configH.UpdateConfig) diff --git a/app/middleware/admin.go b/app/middleware/admin.go index 97178e1..66937f2 100644 --- a/app/middleware/admin.go +++ b/app/middleware/admin.go @@ -9,8 +9,8 @@ import ( ) // RequireAdmin returns a handler that aborts with 403 unless the authenticated identity is an -// admin according to the live *config.Config (same rules as AuthHandler.Me). -func RequireAdmin(cfg *config.Config) gin.HandlerFunc { +// admin according to the current config snapshot (same rules as AuthHandler.Me). +func RequireAdmin(live *config.LiveConfig) gin.HandlerFunc { return func(c *gin.Context) { emailVal, ok := c.Get("user_email") if !ok { @@ -18,7 +18,7 @@ func RequireAdmin(cfg *config.Config) gin.HandlerFunc { return } email, _ := emailVal.(string) - if config.UserIsAdmin(cfg, email) { + if config.UserIsAdmin(live.Snapshot(), email) { c.Next() return } From c77a6da5a33595cda3be98f513bd133235d1083e Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Thu, 26 Mar 2026 14:47:20 +0100 Subject: [PATCH 11/11] feat(config): live realod oauth user params --- app/config/live.go | 36 ++++++++++++++++++++++++++++++++++++ app/config/live_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 app/config/live.go create mode 100644 app/config/live_test.go diff --git a/app/config/live.go b/app/config/live.go new file mode 100644 index 0000000..973c305 --- /dev/null +++ b/app/config/live.go @@ -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) +} diff --git a/app/config/live_test.go b/app/config/live_test.go new file mode 100644 index 0000000..5417499 --- /dev/null +++ b/app/config/live_test.go @@ -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() +}