mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 00:01:20 +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,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)
|
||||
}
|
||||
Reference in new issue
Block a user