package externaladmin import ( "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/appconfig" "hyapp-admin-server/internal/modules/appuser" "hyapp-admin-server/internal/modules/hostorg" "hyapp-admin-server/internal/modules/prettyid" "hyapp-admin-server/internal/modules/resource" "hyapp-admin-server/internal/modules/roomadmin" "hyapp-admin-server/internal/modules/upload" "hyapp-admin-server/internal/modules/vipconfig" "github.com/gin-gonic/gin" ) type BusinessHandlers struct { AppUser *appuser.Handler HostOrg *hostorg.Handler RoomAdmin *roomadmin.Handler Resource *resource.Handler PrettyID *prettyid.Handler AppConfig *appconfig.Handler VIPConfig *vipconfig.Handler Upload *upload.Handler } func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) { if protected == nil || h == nil { return } protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts) protected.GET("/admin/external-admin-users/permission-catalog", middleware.RequirePermission("external-admin-user:permissions"), h.ListPermissionCatalog) protected.GET("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.GetAccountPermissions) protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget) protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), middleware.RequirePermission("external-admin-user:permissions"), h.CreateAccount) protected.PUT("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateAccountPermissions) protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus) protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword) } func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { if api == nil || h == nil { return } external := api.Group("/external") auth := external.Group("/auth") // Credentials, session state and CSRF secrets must never be cached by browsers, // service workers or intermediary proxies. This middleware runs before session // auth so 4xx/5xx responses carry the same protection as successful responses. auth.Use(noStore()) auth.POST("/login", h.Login) protectedAuth := auth.Group("") // Audit wraps CSRF so forged writes are recorded as failed attempts instead of disappearing // before the audit middleware gets control. protectedAuth.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF()) protectedAuth.GET("/me", h.Me) protectedAuth.POST("/logout", h.Logout) protectedAuth.POST("/change-password", h.ChangePassword) // Password changes remain available as a self-service action, but they are not a // business-route gate. Legacy rows may still carry password_change_required=true; // authorization must therefore depend only on the active session and permissions. business := external.Group("") business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF()) registerBusinessWhitelist(business, h, handlers) } func noStore() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Cache-Control", "no-store") c.Header("Pragma", "no-cache") c.Next() } } func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { // "My team" is not a generic manager-list alias. Its handler derives the root // manager from the external session and never accepts a client-selected user ID. group.GET("/admin/my-team/agencies", middleware.RequirePermission("team:view"), h.ListMyTeamAgencies) if handlers.AppUser != nil { group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), handlers.AppUser.ListUsers) group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), handlers.AppUser.ListBannedUsers) // Detail is a support read for each user-targeted action, not a second product // capability. Any independently assigned user action can resolve its exact target. group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), handlers.AppUser.GetUser) group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), handlers.AppUser.UpdateUser) group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), handlers.AppUser.AdjustTemporaryLevels) group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), handlers.AppUser.BanUser) group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), handlers.AppUser.UnbanUser) } if handlers.HostOrg != nil { group.GET("/admin/hosts", middleware.RequirePermission("host:list"), handlers.HostOrg.ListHosts) group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), handlers.HostOrg.ListAgencies) group.GET("/admin/bds", middleware.RequirePermission("bd:list"), handlers.HostOrg.ListBDs) // The shared host-org API names are historical: external BD Manager uses // /managers, while the Super Admin projection uses /bd-leaders. group.GET("/admin/bd-leaders", middleware.RequirePermission("super-admin:list"), handlers.HostOrg.ListBDLeaders) group.GET("/admin/managers", middleware.RequirePermission("bd-manager:list"), handlers.HostOrg.ListManagers) } if handlers.RoomAdmin != nil { group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update"), handlers.RoomAdmin.ListRooms) group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom) } if handlers.Resource != nil { // Resource owner data cannot yet classify generic entitlements into the three // product labels below. The assignment validator therefore keeps them atomic, // and the route repeats all three checks so corrupt DB JSON still fails closed. group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources) group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), handlers.Resource.ListResourceGrants) group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), handlers.Resource.LookupResourceGrantTarget) group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.GrantExternalResource) // External UI never uses group grants. Without an owner-level semantic category, // a mixed group cannot be authorized safely, so it is intentionally not exposed. } if handlers.PrettyID != nil { group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.ListIDs) group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant) } if handlers.AppConfig != nil { group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners) group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner) } if handlers.VIPConfig != nil { // Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped // grant permission so the portal can select direct-VIP vs trial-card without config write access. group.GET("/admin/activity/vip-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram) group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.ListLevels) group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIPTrialCard) group.POST("/admin/activity/vip-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIP) } if handlers.Upload != nil { // Banner/avatar forms need a real upload endpoint; no other file-management route is exposed. group.POST("/admin/files/image/upload", middleware.RequirePermission("banner:create"), handlers.Upload.UploadImage) } }