forked from baron/baron-sso
Merge branch 'dev' into feat/org-chart-rebac
This commit is contained in:
@@ -7,8 +7,11 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,15 +25,39 @@ type DevHandler struct {
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
RPSvc service.RelyingPartyService
|
||||
Auth interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler {
|
||||
func NewDevHandler(
|
||||
redis domain.RedisRepository,
|
||||
secretRepo domain.ClientSecretRepository,
|
||||
consentRepo repository.ClientConsentRepository,
|
||||
rpSvc service.RelyingPartyService,
|
||||
keto service.KetoService,
|
||||
auth ...interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
},
|
||||
) *DevHandler {
|
||||
var authProvider interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
if len(auth) > 0 {
|
||||
authProvider = auth[0]
|
||||
}
|
||||
|
||||
return &DevHandler{
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
SecretRepo: secretRepo,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
RPSvc: rpSvc,
|
||||
Auth: authProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +121,142 @@ type clientUpsertRequest struct {
|
||||
Metadata *map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if (!ok || profile == nil) && h.Auth != nil {
|
||||
enriched, err := h.Auth.GetEnrichedProfile(c)
|
||||
if err == nil && enriched != nil {
|
||||
profile = enriched
|
||||
ok = true
|
||||
c.Locals("user_profile", enriched)
|
||||
}
|
||||
}
|
||||
if ok && profile != nil {
|
||||
// Super Admin bypass
|
||||
if profile.Role == domain.RoleSuperAdmin {
|
||||
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
||||
return true, nil
|
||||
}
|
||||
if isAdminEmail(profile.Email) {
|
||||
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization"))
|
||||
if isAdminEmail(tokenEmail) {
|
||||
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
||||
return true, nil
|
||||
}
|
||||
if tokenSubject == "" {
|
||||
if isTrustedLocalDevfrontRequest(c) {
|
||||
// Local devfront fallback: allow localhost developer flow even if auth context is missing.
|
||||
slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin"))
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Fallback: resolve role from Kratos identity traits when user_profile is not injected.
|
||||
if h.KratosAdmin != nil {
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
|
||||
if err == nil && identity != nil {
|
||||
if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin {
|
||||
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
|
||||
return true, nil
|
||||
}
|
||||
if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) {
|
||||
slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check with Keto: System:AppManager#member
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed)
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||
authHeader = strings.TrimSpace(authHeader)
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(authHeader[len("Bearer "):])
|
||||
if token == "" || strings.Count(token, ".") != 2 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
sub := ""
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
sub = strings.TrimSpace(sub)
|
||||
}
|
||||
email := ""
|
||||
if claimEmail, ok := claims["email"].(string); ok {
|
||||
email = strings.TrimSpace(claimEmail)
|
||||
}
|
||||
|
||||
return sub, email
|
||||
}
|
||||
|
||||
func isAdminEmail(email string) bool {
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
||||
}
|
||||
|
||||
func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
origin := strings.ToLower(strings.TrimSpace(c.Get("Origin")))
|
||||
referer := strings.ToLower(strings.TrimSpace(c.Get("Referer")))
|
||||
allowedPrefixes := []string{
|
||||
"http://localhost:5174",
|
||||
"https://localhost:5174",
|
||||
}
|
||||
|
||||
for _, prefix := range allowedPrefixes {
|
||||
if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
@@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to check app manager permission", "error", err)
|
||||
}
|
||||
|
||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
items = append(items, h.mapClientSummary(client))
|
||||
summary := h.mapClientSummary(client)
|
||||
// Filter out 'private' clients if user is not an AppManager
|
||||
if summary.Type == "private" && !isAppManager {
|
||||
continue
|
||||
}
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
return c.JSON(clientListResponse{
|
||||
@@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*client)
|
||||
|
||||
// Check permission for private clients
|
||||
if summary.Type == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Endpoints: clientEndpoints{
|
||||
@@ -175,6 +360,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"})
|
||||
}
|
||||
|
||||
// [Security] Check permission before patching
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
@@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
|
||||
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
|
||||
|
||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential")))
|
||||
if clientType != "public" && clientType != "confidential" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
||||
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
if clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"})
|
||||
}
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
|
||||
@@ -236,10 +444,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
metadata = map[string]interface{}{}
|
||||
}
|
||||
metadata["status"] = status
|
||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||
if tokenAuthMethod == "" {
|
||||
if clientType == "public" {
|
||||
if clientType == "pkce" {
|
||||
tokenAuthMethod = "none"
|
||||
} else {
|
||||
tokenAuthMethod = "client_secret_basic"
|
||||
@@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
clientType := ""
|
||||
if req.Type != nil {
|
||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||
if clientType != "public" && clientType != "confidential" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"})
|
||||
if clientType != "pkce" && clientType != "private" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"})
|
||||
}
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients (both current and new type)
|
||||
currentSummary := h.mapClientSummary(*current)
|
||||
if currentSummary.Type == "private" || clientType == "private" {
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
|
||||
}
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +546,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
|
||||
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
||||
if tokenAuthMethod == "" && clientType != "" {
|
||||
if clientType == "public" {
|
||||
if clientType == "pkce" {
|
||||
tokenAuthMethod = "none"
|
||||
} else {
|
||||
tokenAuthMethod = "client_secret_basic"
|
||||
@@ -382,6 +603,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
@@ -517,14 +750,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||
}
|
||||
|
||||
// [Security] Check permission for private clients
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err == nil {
|
||||
summary := h.mapClientSummary(*current)
|
||||
if summary.Type == "private" {
|
||||
isAppManager, _ := h.checkAppManagerPermission(c)
|
||||
if !isAppManager {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Generate new secret
|
||||
newSecret, err := generateRandomSecret(20)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
|
||||
}
|
||||
|
||||
// 2. Get current client to preserve other fields
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
// 2. Get current client to preserve other fields (already fetched above)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||
@@ -578,15 +822,22 @@ func generateRandomSecret(length int) (string, error) {
|
||||
|
||||
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
status := "active"
|
||||
var createdAt *time.Time
|
||||
|
||||
if client.Metadata != nil {
|
||||
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
||||
status = "inactive"
|
||||
}
|
||||
if value, ok := client.Metadata["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
createdAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientType := "confidential"
|
||||
clientType := "private"
|
||||
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
||||
clientType = "public"
|
||||
clientType = "pkce"
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(client.ClientName)
|
||||
@@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
Name: name,
|
||||
Type: clientType,
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
RedirectURIs: client.RedirectURIs,
|
||||
Scopes: scopes,
|
||||
ClientSecret: clientSecret,
|
||||
|
||||
Reference in New Issue
Block a user