Files
xinfra/server/internal/handler/wayne_role_binding.go
T

330 lines
10 KiB
Go
Raw Normal View History

package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/1024XEngineer/xinfra/server/internal/service"
"github.com/gin-gonic/gin"
)
type WayneRoleBindingHandler struct {
wayne *service.WayneRoleBindingService
audit *service.AuditService
}
func NewWayneRoleBindingHandler(wayne *service.WayneRoleBindingService, audit *service.AuditService) *WayneRoleBindingHandler {
return &WayneRoleBindingHandler{wayne: wayne, audit: audit}
}
func (h *WayneRoleBindingHandler) BindNamespace(c *gin.Context) {
h.handle(c, "namespace", http.MethodPut)
}
func (h *WayneRoleBindingHandler) UnbindNamespace(c *gin.Context) {
h.handle(c, "namespace", http.MethodDelete)
}
func (h *WayneRoleBindingHandler) BindApp(c *gin.Context) {
h.handle(c, "app", http.MethodPut)
}
func (h *WayneRoleBindingHandler) UnbindApp(c *gin.Context) {
h.handle(c, "app", http.MethodDelete)
}
func (h *WayneRoleBindingHandler) ListNamespaces(c *gin.Context) {
h.handleQuery(c, "namespaces", 0, "")
}
func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) {
var groupType *int
if raw := strings.TrimSpace(c.Query("type")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || (value != 0 && value != 1) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"})
return
}
groupType = &value
}
result, err := h.wayne.ListGroups(c.Request.Context(), groupType)
if err != nil {
h.writeQueryAudit(c, "groups", 0, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, "groups", 0, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) {
username := strings.TrimSpace(c.Param("username"))
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"})
return
}
h.handleQuery(c, "user_roles", 0, username)
}
func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) {
namespaceID, ok := parseUintPathParam(c, "namespaceid")
if !ok {
return
}
h.handleOperatorPermissions(c, "namespace", namespaceID)
}
func (h *WayneRoleBindingHandler) AppOperatorPermissions(c *gin.Context) {
appID, ok := parseUintPathParam(c, "appid")
if !ok {
return
}
h.handleOperatorPermissions(c, "app", appID)
}
func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method string) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
operatorEmail := strings.TrimSpace(claims.Email)
if operatorEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
return
}
resourceParam := "namespaceid"
if scope == "app" {
resourceParam = "appid"
}
resourceID, ok := parseUintPathParam(c, resourceParam)
if !ok {
return
}
req, ok := parseRoleBindingRequest(c)
if !ok {
return
}
targetUsername, ok := roleBindingTargetUsername(c, req)
2026-07-17 15:47:32 +08:00
if !ok {
return
}
if method == http.MethodPut && len(req.GroupIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"})
return
}
result, err := h.call(c, scope, method, resourceID, targetUsername, operatorEmail, req)
if err != nil {
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "deny", req.RequestID, err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "allow", req.RequestID, "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) {
var result *service.WayneRoleBindingResult
var err error
switch resourceType {
case "namespaces":
result, err = h.wayne.ListNamespaces(c.Request.Context())
case "user_roles":
result, err = h.wayne.GetUserRoles(c.Request.Context(), username)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"})
return
}
if err != nil {
h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scope string, resourceID uint64) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
operatorEmail := strings.TrimSpace(claims.Email)
if operatorEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
return
}
var result *service.WayneRoleBindingResult
var err error
if scope == "namespace" {
result, err = h.wayne.NamespaceOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
} else {
result, err = h.wayne.AppOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
}
resourceType := scope + "_operator_permissions"
if err != nil {
h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, targetUsername string, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) {
if scope == "namespace" {
if method == http.MethodPut {
return h.wayne.BindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
}
return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
}
if method == http.MethodPut {
return h.wayne.BindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
}
return h.wayne.UnbindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
}
func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) {
var req service.WayneRoleBindingRequest
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return req, false
}
if strings.TrimSpace(string(body)) == "" {
return req, true
}
if err := json.Unmarshal(body, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return req, false
}
return req, true
}
func roleBindingTargetUsername(c *gin.Context, req service.WayneRoleBindingRequest) (string, bool) {
if username := strings.TrimSpace(req.Username); username != "" {
return username, true
2026-07-17 15:47:32 +08:00
}
for _, key := range []string{"username", "userName", "user_name"} {
2026-07-17 15:47:32 +08:00
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
continue
}
return raw, true
2026-07-17 15:47:32 +08:00
}
c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"})
return "", false
2026-07-17 15:47:32 +08:00
}
func parseUintPathParam(c *gin.Context, name string) (uint64, bool) {
raw := strings.TrimSpace(c.Param(name))
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s", name)})
return 0, false
}
return value, true
}
func writeWayneRoleBindingResult(c *gin.Context, result *service.WayneRoleBindingResult) {
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": gin.H{}})
return
}
contentType := result.ContentType
if contentType == "" {
contentType = "application/json"
}
if len(result.Body) == 0 {
c.Status(result.StatusCode)
return
}
c.Data(result.StatusCode, contentType, result.Body)
}
func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBindingResult, err error) {
if result != nil && len(result.Body) > 0 {
contentType := result.ContentType
if contentType == "" {
contentType = "application/json"
}
c.Data(result.StatusCode, contentType, result.Body)
return
}
var httpErr *service.WayneRoleBindingHTTPError
switch {
case errors.Is(err, service.ErrWayneRoleBindingNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrWayenEmailMissing):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.As(err, &httpErr):
c.JSON(httpErr.StatusCode, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) {
h.audit.Write(service.AuditEntry{
RequestID: requestID,
ActorUserID: userID,
ActorUsername: operatorEmail,
ClientIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Action: "wayne.role_binding." + decision,
ResourceType: "wayne_" + scope,
ResourceID: strconv.FormatUint(resourceID, 10),
ScopeType: scope,
ScopeID: resourceID,
Decision: decision,
2026-07-17 15:47:32 +08:00
Reason: truncateAuditReason(reason),
Metadata: map[string]any{
"targetUsername": targetUsername,
},
})
}
func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType string, resourceID uint64, decision, reason string) {
claims, _ := CurrentClaims(c)
var actorUserID uint64
var actorUsername string
if claims != nil {
actorUserID = claims.UserID
actorUsername = strings.TrimSpace(claims.Email)
if actorUsername == "" {
actorUsername = claims.Username
}
}
h.audit.Write(service.AuditEntry{
ActorUserID: actorUserID,
ActorUsername: actorUsername,
ClientIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Action: "wayne.role_query." + decision,
ResourceType: "wayne_" + resourceType,
ResourceID: strconv.FormatUint(resourceID, 10),
Decision: decision,
2026-07-17 15:47:32 +08:00
Reason: truncateAuditReason(reason),
})
}
2026-07-17 15:47:32 +08:00
func truncateAuditReason(reason string) string {
const maxReasonBytes = 512
if len(reason) <= maxReasonBytes {
return reason
}
return reason[:maxReasonBytes]
}