mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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>
This commit is contained in:
1 parent
1214108d6b
commit
c6166de886
36 files changed
+1729
-109
No files matched your search
@@ -0,0 +1,14 @@
|
||||
-- +goose Up
|
||||
-- Instance-level settings. In-app values override env vars; secrets are
|
||||
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
|
||||
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
|
||||
CREATE TABLE core.settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.settings;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1;
|
||||
|
||||
-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key;
|
||||
|
||||
-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $1;
|
||||
@@ -92,6 +92,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
return nil, fmt.Errorf("encryption key: %w", err)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
||||
settings := services.NewSettingsService(repo, encryptor)
|
||||
|
||||
if cfg.Env == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -126,10 +127,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
||||
UserPerMinute: cfg.UserRateLimitPerMin,
|
||||
},
|
||||
RateLimitResolver: settings.RateLimitPerMinute,
|
||||
}.ResolveActor())
|
||||
|
||||
handler := &handlers.HTTPHandler{
|
||||
Platform: platform,
|
||||
Settings: settings,
|
||||
Validate: validator.New(),
|
||||
Metrics: metrics,
|
||||
Readiness: func(c *gin.Context) map[string]any {
|
||||
|
||||
@@ -336,6 +336,14 @@ type CoreScheduledJobRun struct {
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
}
|
||||
|
||||
type CoreSetting struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AuthSubject string `json:"auth_subject"`
|
||||
|
||||
@@ -46,6 +46,7 @@ type Querier interface {
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
|
||||
DeleteSetting(ctx context.Context, key string) error
|
||||
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
|
||||
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||
@@ -68,6 +69,7 @@ type Querier interface {
|
||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
|
||||
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
|
||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||
@@ -97,6 +99,7 @@ type Querier interface {
|
||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
||||
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
||||
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
|
||||
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
|
||||
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
|
||||
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
|
||||
@@ -132,6 +135,7 @@ type Querier interface {
|
||||
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
||||
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: settings.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteSetting = `-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
|
||||
_, err := q.db.Exec(ctx, deleteSetting, key)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSetting = `-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
type GetSettingRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetSetting(ctx context.Context, key string) (GetSettingRow, error) {
|
||||
row := q.db.QueryRow(ctx, getSetting, key)
|
||||
var i GetSettingRow
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSettings = `-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key
|
||||
`
|
||||
|
||||
type ListSettingsRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSettings(ctx context.Context) ([]ListSettingsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSettingsRow
|
||||
for rows.Next() {
|
||||
var i ListSettingsRow
|
||||
if err := rows.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertSetting = `-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING key, value, secret, updated_by, updated_at
|
||||
`
|
||||
|
||||
type UpsertSettingParams struct {
|
||||
Key string `json:"key"`
|
||||
Column2 []byte `json:"column_2"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) {
|
||||
row := q.db.QueryRow(ctx, upsertSetting,
|
||||
arg.Key,
|
||||
arg.Column2,
|
||||
arg.Secret,
|
||||
arg.UpdatedBy,
|
||||
)
|
||||
var i CoreSetting
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedBy,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
type HTTPHandler struct {
|
||||
Platform *services.PlatformService
|
||||
Settings *services.SettingsService
|
||||
Validate *validator.Validate
|
||||
Metrics *observability.Metrics
|
||||
Readiness func(*gin.Context) map[string]any
|
||||
@@ -33,6 +34,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/me", h.me)
|
||||
api.GET("/instance/public", h.instancePublic)
|
||||
api.GET("/instance/settings", h.listInstanceSettings)
|
||||
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
|
||||
api.POST("/bootstrap", h.bootstrap)
|
||||
api.GET("/organizations", h.organizations)
|
||||
api.POST("/organizations", h.createOrganization)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
// instancePublic serves unauthenticated bootstrap state to the login page.
|
||||
// It intentionally exposes only what the sign-in UI needs.
|
||||
func (h *HTTPHandler) instancePublic(c *gin.Context) {
|
||||
users, err := h.Settings.AuthUserCount(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
signupEnabled := false
|
||||
if users > 0 {
|
||||
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"signup_enabled": signupEnabled,
|
||||
"bootstrap_required": users == 0,
|
||||
"social_providers": managedSocialProviders(),
|
||||
})
|
||||
}
|
||||
|
||||
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
|
||||
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
|
||||
func managedSocialProviders() []string {
|
||||
if os.Getenv("PRIMORA_MANAGED") != "true" {
|
||||
return []string{}
|
||||
}
|
||||
providers := []string{}
|
||||
for _, p := range []struct{ name, envKey string }{
|
||||
{"github", "GITHUB_CLIENT_ID"},
|
||||
{"google", "GOOGLE_CLIENT_ID"},
|
||||
{"discord", "DISCORD_CLIENT_ID"},
|
||||
{"microsoft", "MICROSOFT_CLIENT_ID"},
|
||||
} {
|
||||
if os.Getenv(p.envKey) != "" {
|
||||
providers = append(providers, p.name)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
|
||||
// `user.role`), which is distinct from org/project roles.
|
||||
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
|
||||
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
settings, err := h.Settings.ListResolved(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Value json.RawMessage `json:"value" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
|
||||
return
|
||||
}
|
||||
actor, _ := middleware.ActorFromContext(c)
|
||||
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
|
||||
return
|
||||
}
|
||||
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
spec := services.SettingSpecs[key]
|
||||
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
|
||||
if !spec.Secret {
|
||||
resp["value"] = value
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
|
||||
Redis *redis.Client
|
||||
Verifier *auth.Verifier
|
||||
RateLimits RateLimitConfig
|
||||
// RateLimitResolver overrides RateLimits with live instance settings when
|
||||
// set — it resolves "user" and "api_key" limits per request (cached).
|
||||
RateLimitResolver func(ctx context.Context, scope string) int
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
|
||||
UserPerMinute int
|
||||
}
|
||||
|
||||
func (m AuthMiddleware) rateLimit(ctx context.Context, scope string, fallback int) int {
|
||||
if m.RateLimitResolver == nil {
|
||||
return fallback
|
||||
}
|
||||
if v := m.RateLimitResolver(ctx, scope); v >= 0 {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.Request.Header.Get("X-Request-ID")
|
||||
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
if actor.UserID != nil {
|
||||
userIdentity = actor.UserID.String()
|
||||
}
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.rateLimit(c.Request.Context(), "user", m.RateLimits.UserPerMinute), "User rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
c.Set(actorKey, actor)
|
||||
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
||||
return
|
||||
}
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.rateLimit(c.Request.Context(), "api_key", m.RateLimits.APIKeyPerMinute), "API key rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
|
||||
|
||||
@@ -56,3 +56,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
|
||||
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
||||
return r.queries.TouchAPIKey(ctx, id)
|
||||
}
|
||||
|
||||
// Auth-user lookups hit the better-auth tables in the public schema. They are
|
||||
// raw queries on purpose: sqlc only knows the core schema, and a view would
|
||||
// break when the backend migrates before the auth service has created its
|
||||
// tables.
|
||||
func (r *CoreRepository) GetAuthUserRole(ctx context.Context, authSubject string) (string, error) {
|
||||
var role string
|
||||
err := r.pool.QueryRow(ctx, `select coalesce(role, 'user') from public."user" where id = $1`, authSubject).Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) CountAuthUsers(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := r.pool.QueryRow(ctx, `select count(*) from public."user"`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) GetSetting(ctx context.Context, key string) (db.GetSettingRow, error) {
|
||||
return r.queries.GetSetting(ctx, key)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) ListSettings(ctx context.Context) ([]db.ListSettingsRow, error) {
|
||||
return r.queries.ListSettings(ctx)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) UpsertSetting(ctx context.Context, params db.UpsertSettingParams) (db.CoreSetting, error) {
|
||||
return r.queries.UpsertSetting(ctx, params)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) DeleteSetting(ctx context.Context, key string) error {
|
||||
return r.queries.DeleteSetting(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
boolSpec := SettingSpecs["auth.signup_enabled"]
|
||||
intSpec := SettingSpecs["ratelimit.user_per_minute"]
|
||||
strSpec := SettingSpecs["mail.smtp_host"]
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
spec SettingSpec
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bool true", spec: boolSpec, raw: `true`, want: "true"},
|
||||
{name: "bool false", spec: boolSpec, raw: `false`, want: "false"},
|
||||
{name: "bool rejects string", spec: boolSpec, raw: `"true"`, wantErr: true},
|
||||
{name: "bool rejects int", spec: boolSpec, raw: `1`, wantErr: true},
|
||||
{name: "int accepts", spec: intSpec, raw: `120`, want: "120"},
|
||||
{name: "int zero allowed", spec: intSpec, raw: `0`, want: "0"},
|
||||
{name: "int rejects negative", spec: intSpec, raw: `-5`, wantErr: true},
|
||||
{name: "int rejects float", spec: intSpec, raw: `1.5`, wantErr: true},
|
||||
{name: "int rejects string", spec: intSpec, raw: `"120"`, wantErr: true},
|
||||
{name: "string accepts", spec: strSpec, raw: `"smtp.example.com"`, want: "smtp.example.com"},
|
||||
{name: "string rejects number", spec: strSpec, raw: `42`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeSetting(tc.spec, json.RawMessage(tc.raw))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedSettingKeysDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := sortedSettingKeys()
|
||||
second := sortedSettingKeys()
|
||||
if len(first) != len(SettingSpecs) {
|
||||
t.Fatalf("expected %d keys, got %d", len(SettingSpecs), len(first))
|
||||
}
|
||||
for i := range first {
|
||||
if first[i] != second[i] {
|
||||
t.Fatalf("key order not deterministic at %d: %q vs %q", i, first[i], second[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ tags:
|
||||
- name: Integrations
|
||||
- name: Webhooks
|
||||
- name: Automation
|
||||
- name: Instance
|
||||
paths:
|
||||
/health/liveness:
|
||||
get:
|
||||
@@ -67,6 +68,87 @@ paths:
|
||||
$ref: "#/components/schemas/MeResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/instance/public:
|
||||
get:
|
||||
tags: [Instance]
|
||||
operationId: getInstancePublic
|
||||
summary: Public instance state for the login page — no auth required
|
||||
responses:
|
||||
"200":
|
||||
description: Public instance configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstancePublicConfig"
|
||||
/instance/settings:
|
||||
get:
|
||||
tags: [Instance]
|
||||
operationId: listInstanceSettings
|
||||
summary: List instance settings (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved instance settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceSettingsResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/instance/settings/{key}:
|
||||
put:
|
||||
tags: [Instance]
|
||||
operationId: updateInstanceSetting
|
||||
summary: Set an instance setting override (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateInstanceSettingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Setting stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceSetting"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Instance]
|
||||
operationId: deleteInstanceSetting
|
||||
summary: Revert a setting to its env/default value (platform admin only)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: key
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Setting reverted
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/bootstrap:
|
||||
post:
|
||||
tags: [Platform]
|
||||
@@ -1952,6 +2034,54 @@ components:
|
||||
type: string
|
||||
required: [code, message]
|
||||
required: [status, error]
|
||||
InstancePublicConfig:
|
||||
type: object
|
||||
properties:
|
||||
signup_enabled:
|
||||
type: boolean
|
||||
description: Whether public sign-up is open (bootstrap always allowed)
|
||||
bootstrap_required:
|
||||
type: boolean
|
||||
description: True when no user exists yet — first sign-up becomes instance admin
|
||||
social_providers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Enabled OAuth providers — empty on self-hosted
|
||||
required: [signup_enabled, bootstrap_required, social_providers]
|
||||
InstanceSetting:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [bool, int, string]
|
||||
secret:
|
||||
type: boolean
|
||||
is_set:
|
||||
type: boolean
|
||||
description: True when an in-app override exists
|
||||
source:
|
||||
type: string
|
||||
enum: [app, env, default]
|
||||
value:
|
||||
description: Effective value — never returned for secret keys
|
||||
required: [key, type, secret, is_set, source]
|
||||
InstanceSettingsResponse:
|
||||
type: object
|
||||
properties:
|
||||
settings:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceSetting"
|
||||
required: [settings]
|
||||
UpdateInstanceSettingRequest:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
description: JSON value matching the key's declared type
|
||||
required: [value]
|
||||
BootstrapRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in new issue
Block a user