Files
Tomas DvorakandDevin c6166de886 feat: instance administration, bootstrap admin, and managed-tier OAuth gate
Self-hosted instances are now email/password only — OAuth providers
(GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true,
reserved for the future hosted tier.

Sign-up lifecycle: the first public sign-up becomes the instance admin; after
bootstrap, sign-up closes by default. A better-auth databaseHook gates only
/sign-up/email, so admin-created users and OAuth flows bypass it. The login
page reads GET /instance/public to hide the sign-up tab, show the bootstrap
notice, and render only the providers the instance offers.

Instance settings live in core.settings (migration 00010) and resolve
in-app > env > built-in default, cached 15s. Platform admins (better-auth
user.role) manage sign-up, mail transport, and rate limits via
GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret
values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never
returned by the API, and decrypted by the auth service at send time.

Auth service reads the same settings table for the sign-up gate and mail
config, so in-app changes take effect without a restart.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:46:47 +02:00

346 lines
10 KiB
Go

package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strconv"
"sync"
"time"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// SettingSpec describes a settable instance key: value type, the env var used
// as fallback when no DB row exists, and a built-in default.
type SettingSpec struct {
Key string
Type string // "bool" | "int" | "string"
Secret bool
EnvKey string
Default string
}
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
// defaults — an app value wins when present.
var SettingSpecs = map[string]SettingSpec{
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
}
const settingsCacheTTL = 15 * time.Second
// resolvedSetting holds the stored string form: plaintext for normal keys,
// base64 ciphertext for secrets — Resolve() decrypts on read.
type resolvedSetting struct {
value string
secret bool
}
// SettingsService resolves instance settings: DB row > env var > default.
// Values cache briefly so per-request consumers (rate limits) stay cheap.
type SettingsService struct {
repo *repositories.CoreRepository
encryptor *secrets.Encryptor
mu sync.Mutex
cache map[string]resolvedSetting
cacheAt time.Time
}
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
return &SettingsService{repo: repo, encryptor: encryptor}
}
// Resolve returns the effective string value plus its source
// ("app" | "env" | "default"). Secret DB values are decrypted.
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
spec, ok := SettingSpecs[key]
if !ok {
return "", "", fmt.Errorf("unknown setting: %s", key)
}
row, err := s.stored(ctx, key)
if err != nil {
return "", "", err
}
if row != nil {
raw := row.value
if row.secret {
raw, err = s.decrypt(raw)
if err != nil {
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
}
}
return raw, "app", nil
}
if env := os.Getenv(spec.EnvKey); env != "" {
return env, "env", nil
}
return spec.Default, "default", nil
}
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return false, err
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
}
return v, nil
}
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return 0, err
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
}
return v, nil
}
// RateLimitPerMinute is the hot-path helper for the auth middleware.
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
key := "ratelimit.user_per_minute"
if scope == "api_key" {
key = "ratelimit.api_key_per_minute"
}
v, err := s.GetInt(ctx, key)
if err != nil {
spec := SettingSpecs[key]
fallback, _ := strconv.Atoi(spec.Default)
return fallback
}
return v
}
// ValidateAndStore type-checks the raw JSON value against the registry and
// persists it. Secret values are encrypted before they touch the DB.
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
spec, ok := SettingSpecs[key]
if !ok {
return fmt.Errorf("unknown setting: %s", key)
}
plain, err := normalizeSetting(spec, raw)
if err != nil {
return err
}
value := []byte(strconv.Quote(plain))
if spec.Secret {
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
}
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
Key: key,
Column2: value,
Secret: spec.Secret,
UpdatedBy: &updatedBy,
})
if err != nil {
return err
}
s.invalidate()
return nil
}
func (s *SettingsService) Delete(ctx context.Context, key string) error {
if _, ok := SettingSpecs[key]; !ok {
return fmt.Errorf("unknown setting: %s", key)
}
if err := s.repo.DeleteSetting(ctx, key); err != nil {
return err
}
s.invalidate()
return nil
}
// IsAdmin reports whether the actor holds the platform admin role
// (better-auth `user.role`), not an org/project role.
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
return err == nil && role == "admin"
}
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
return s.repo.CountAuthUsers(ctx)
}
// ListResolved returns every registered key with its effective state.
// Secret values are masked — never returned.
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
stored, err := s.loadStored(ctx)
if err != nil {
return nil, err
}
out := make([]ResolvedSetting, 0, len(SettingSpecs))
for _, key := range sortedSettingKeys() {
spec := SettingSpecs[key]
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
if row, ok := stored[key]; ok {
entry.IsSet = true
entry.Source = "app"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(row.value))
}
} else if env := os.Getenv(spec.EnvKey); env != "" {
entry.Source = "env"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(env))
}
} else {
entry.Source = "default"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
}
}
out = append(out, entry)
}
return out, nil
}
type ResolvedSetting struct {
Key string `json:"key"`
Type string `json:"type"`
Secret bool `json:"secret"`
IsSet bool `json:"is_set"`
Source string `json:"source"`
Value json.RawMessage `json:"value,omitempty"`
}
func sortedSettingKeys() []string {
keys := make([]string, 0, len(SettingSpecs))
for k := range SettingSpecs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
return s.cache, nil
}
rows, err := s.repo.ListSettings(ctx)
if err != nil {
return nil, err
}
fresh := make(map[string]resolvedSetting, len(rows))
for _, r := range rows {
encoded, err := unwrapSettingValue(r.Value)
if err != nil {
continue // unreadable row — treat as unset rather than break consumers
}
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
}
s.cache = fresh
s.cacheAt = time.Now()
return fresh, nil
}
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
all, err := s.loadStored(ctx)
if err != nil {
// cold-cache fallback: direct lookup
row, err := s.repo.GetSetting(ctx, key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
encoded, err := unwrapSettingValue(row.Value)
if err != nil {
return nil, err
}
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
}
if row, ok := all[key]; ok {
return &row, nil
}
return nil, nil
}
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
plain, err := s.encryptor.Decrypt(raw)
if err != nil {
return "", err
}
return string(plain), nil
}
func (s *SettingsService) invalidate() {
s.mu.Lock()
defer s.mu.Unlock()
s.cache = nil
}
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
// string — the plaintext for normal keys, base64 ciphertext for secrets.
func unwrapSettingValue(raw []byte) (string, error) {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return "", err
}
return encoded, nil
}
// normalizeSetting validates raw JSON against the spec type and returns the
// canonical string form stored/compared internally.
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
switch spec.Type {
case "bool":
var v bool
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a boolean", spec.Key)
}
return strconv.FormatBool(v), nil
case "int":
var v int
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects an integer", spec.Key)
}
if v < 0 {
return "", fmt.Errorf("%s must be >= 0", spec.Key)
}
return strconv.Itoa(v), nil
default:
var v string
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a string", spec.Key)
}
if len(v) > 512 {
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
}
return v, nil
}
}