From e3fe20005b2275427ac2a11874d99c233786a719 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 17 Dec 2025 22:08:45 +0900 Subject: [PATCH] feat(auth): implement admin management with RootUID enforcement and SetAdmin handler --- .env.example | 1 + backend/cmd/server/main.go | 6 ++-- backend/internal/auth/firebase.go | 13 +++++++++ backend/internal/config/config.go | 2 ++ backend/internal/http/handlers.go | 44 +++++++++++++++++++++++++++-- backend/internal/http/middleware.go | 21 ++++++++++++++ backend/internal/http/types.go | 6 ++++ 7 files changed, 87 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 2b902bb..f65c41c 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ POSTGRES_USER=admin POSTGRES_PASSWORD=admin TZ=Asia/Tokyo COMPOSE_PLATFORM=linux/arm64/v8 +ROOT_ADMIN_UID=1 # App runtime (inside Docker network) PGHOST=db diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 31a6b94..7de4195 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -43,19 +43,19 @@ func main() { // 3) wiring episodeRepo := repo.NewEpisodeRepo(pool) episodeSvc := service.NewEpisodeService(episodeRepo) - var verifier auth.TokenVerifier + var authMgr auth.AdminManager if cfg.Auth.Enabled { v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase) if err != nil { log.Fatalf("firebase auth init failed: %v", err) } - verifier = v + authMgr = v log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID) } else { log.Printf("auth disabled (AUTH_ENABLED=false)") } - router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled) + router := httpapi.NewRouter(episodeSvc, authMgr, cfg.Auth.Enabled, cfg.Auth.RootUID) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // 4) HTTP server with timeouts diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go index dac2275..525d6a8 100644 --- a/backend/internal/auth/firebase.go +++ b/backend/internal/auth/firebase.go @@ -16,6 +16,12 @@ type TokenVerifier interface { Verify(ctx context.Context, token string) (*fbauth.Token, error) } +// AdminManager can set admin claims. +type AdminManager interface { + TokenVerifier + SetAdmin(ctx context.Context, uid string, admin bool) error +} + // FirebaseAuth verifies Firebase ID tokens. type FirebaseAuth struct { client *fbauth.Client @@ -46,3 +52,10 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) { return f.client.VerifyIDToken(ctx, token) } + +// SetAdmin sets or clears the custom claim "admin" for a given uid. +func (f *FirebaseAuth) SetAdmin(ctx context.Context, uid string, admin bool) error { + return f.client.SetCustomUserClaims(ctx, uid, map[string]interface{}{ + "admin": admin, + }) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f7408a2..0a2d097 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { type AuthConfig struct { Enabled bool + RootUID string } type FirebaseConfig struct { @@ -50,6 +51,7 @@ func Load() Config { GinMode: getenv("GIN_MODE", "release"), Auth: AuthConfig{ Enabled: getenvBool("AUTH_ENABLED", false), + RootUID: getenv("ROOT_ADMIN_UID", ""), }, Firebase: FirebaseConfig{ ProjectID: getenv("FIREBASE_PROJECT_ID", ""), diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 0be5f5b..7c2e600 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" ) -func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine { +func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool, rootUID string) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) @@ -32,11 +32,17 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled)) v1.GET("/danime", getDanimeHandler()) - if authEnabled && verifier != nil { + if authEnabled && authMgr != nil { protected := v1.Group("/") - protected.Use(AuthMiddleware(verifier), RequireAdmin()) + protected.Use(AuthMiddleware(authMgr), RequireAdmin()) protected.DELETE("/shows", deleteShowsHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc)) + + if rootUID != "" { + admin := v1.Group("/admin") + admin.Use(AuthMiddleware(authMgr), RequireRootUID(rootUID)) + admin.POST("/grant", setAdminHandler(authMgr)) + } } else { v1.DELETE("/shows", deleteShowsHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc)) @@ -45,6 +51,38 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo return r } +// setAdminHandler godoc +// @Summary Grant/revoke admin +// @Description Set the custom claim "admin" for a user. Requires root admin UID. +// @Tags admin +// @Accept json +// @Produce json +// @Param body body SetAdminReq true "UID and desired admin flag" +// @Success 204 {string} string "no content" +// @Failure 400 {object} HTTPError "invalid payload" +// @Failure 403 {object} HTTPError "forbidden" +// @Failure 500 {object} HTTPError "failed to set claim" +// @Router /api/v1/admin/grant [post] +func setAdminHandler(authMgr auth.AdminManager) gin.HandlerFunc { + return func(c *gin.Context) { + var req SetAdminReq + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.UID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + admin := true + if req.Admin != nil { + admin = *req.Admin + } + if err := authMgr.SetAdmin(c.Request.Context(), req.UID, admin); err != nil { + log.Printf("set admin failed for uid=%s: %v", req.UID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set claim"}) + return + } + c.Status(http.StatusNoContent) + } +} + // GET /healthz func healthzHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) diff --git a/backend/internal/http/middleware.go b/backend/internal/http/middleware.go index 4f01baf..96fed3a 100644 --- a/backend/internal/http/middleware.go +++ b/backend/internal/http/middleware.go @@ -53,3 +53,24 @@ func RequireAdmin() gin.HandlerFunc { c.Next() } } + +// RequireRootUID enforces that the caller's UID matches the configured root admin UID. +func RequireRootUID(rootUID string) gin.HandlerFunc { + return func(c *gin.Context) { + val, ok := c.Get("firebaseToken") + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + token, ok := val.(*fbauth.Token) + if !ok || token.UID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if token.UID != rootUID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + return + } + c.Next() + } +} diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 35fa5f8..063005f 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -70,3 +70,9 @@ type FirebaseOAuthRes struct { Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/"` Expires int64 `json:"expires,omitempty" example:"1700000000"` } + +// SetAdminReq is the request body for POST /api/v1/admin/grant. +type SetAdminReq struct { + UID string `json:"uid" binding:"required" example:"abc123"` + Admin *bool `json:"admin,omitempty" example:"true"` +}