feat(auth): implement admin management with RootUID enforcement and SetAdmin handler

This commit is contained in:
Nik Afiq 2025-12-17 22:08:45 +09:00
parent 889f072390
commit e3fe20005b
7 changed files with 87 additions and 6 deletions

View File

@ -15,6 +15,7 @@ POSTGRES_USER=admin
POSTGRES_PASSWORD=admin POSTGRES_PASSWORD=admin
TZ=Asia/Tokyo TZ=Asia/Tokyo
COMPOSE_PLATFORM=linux/arm64/v8 COMPOSE_PLATFORM=linux/arm64/v8
ROOT_ADMIN_UID=1
# App runtime (inside Docker network) # App runtime (inside Docker network)
PGHOST=db PGHOST=db

View File

@ -43,19 +43,19 @@ func main() {
// 3) wiring // 3) wiring
episodeRepo := repo.NewEpisodeRepo(pool) episodeRepo := repo.NewEpisodeRepo(pool)
episodeSvc := service.NewEpisodeService(episodeRepo) episodeSvc := service.NewEpisodeService(episodeRepo)
var verifier auth.TokenVerifier var authMgr auth.AdminManager
if cfg.Auth.Enabled { if cfg.Auth.Enabled {
v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase) v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase)
if err != nil { if err != nil {
log.Fatalf("firebase auth init failed: %v", err) log.Fatalf("firebase auth init failed: %v", err)
} }
verifier = v authMgr = v
log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID) log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID)
} else { } else {
log.Printf("auth disabled (AUTH_ENABLED=false)") 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)) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 4) HTTP server with timeouts // 4) HTTP server with timeouts

View File

@ -16,6 +16,12 @@ type TokenVerifier interface {
Verify(ctx context.Context, token string) (*fbauth.Token, error) 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. // FirebaseAuth verifies Firebase ID tokens.
type FirebaseAuth struct { type FirebaseAuth struct {
client *fbauth.Client 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) { func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) {
return f.client.VerifyIDToken(ctx, token) 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,
})
}

View File

@ -23,6 +23,7 @@ type Config struct {
type AuthConfig struct { type AuthConfig struct {
Enabled bool Enabled bool
RootUID string
} }
type FirebaseConfig struct { type FirebaseConfig struct {
@ -50,6 +51,7 @@ func Load() Config {
GinMode: getenv("GIN_MODE", "release"), GinMode: getenv("GIN_MODE", "release"),
Auth: AuthConfig{ Auth: AuthConfig{
Enabled: getenvBool("AUTH_ENABLED", false), Enabled: getenvBool("AUTH_ENABLED", false),
RootUID: getenv("ROOT_ADMIN_UID", ""),
}, },
Firebase: FirebaseConfig{ Firebase: FirebaseConfig{
ProjectID: getenv("FIREBASE_PROJECT_ID", ""), ProjectID: getenv("FIREBASE_PROJECT_ID", ""),

View File

@ -15,7 +15,7 @@ import (
"github.com/gin-gonic/gin" "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 := gin.New()
r.Use(gin.Logger(), gin.Recovery()) 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.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
v1.GET("/danime", getDanimeHandler()) v1.GET("/danime", getDanimeHandler())
if authEnabled && verifier != nil { if authEnabled && authMgr != nil {
protected := v1.Group("/") protected := v1.Group("/")
protected.Use(AuthMiddleware(verifier), RequireAdmin()) protected.Use(AuthMiddleware(authMgr), RequireAdmin())
protected.DELETE("/shows", deleteShowsHandler(svc)) protected.DELETE("/shows", deleteShowsHandler(svc))
protected.POST("/archive", moveToArchiveHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc))
if rootUID != "" {
admin := v1.Group("/admin")
admin.Use(AuthMiddleware(authMgr), RequireRootUID(rootUID))
admin.POST("/grant", setAdminHandler(authMgr))
}
} else { } else {
v1.DELETE("/shows", deleteShowsHandler(svc)) v1.DELETE("/shows", deleteShowsHandler(svc))
v1.POST("/archive", moveToArchiveHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc))
@ -45,6 +51,38 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo
return r 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 // GET /healthz
func healthzHandler(c *gin.Context) { func healthzHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})

View File

@ -53,3 +53,24 @@ func RequireAdmin() gin.HandlerFunc {
c.Next() 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()
}
}

View File

@ -70,3 +70,9 @@ type FirebaseOAuthRes struct {
Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"` Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"`
Expires int64 `json:"expires,omitempty" example:"1700000000"` 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"`
}