1
0
forked from baron/baron-sso

Private/PKCE 앱 유형 및 관리자 권한 정책 적용

This commit is contained in:
2026-02-23 13:14:33 +09:00
parent 2624931a7f
commit 2c42986a8b
4 changed files with 169 additions and 36 deletions

View File

@@ -268,7 +268,7 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo) auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler(ketoService) adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService) userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)

View File

@@ -9,6 +9,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
@@ -22,15 +23,19 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
Keto service.KetoService
RPSvc service.RelyingPartyService
} }
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) *DevHandler {
return &DevHandler{ return &DevHandler{
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
Redis: redis, Redis: redis,
SecretRepo: secretRepo, SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
Keto: keto,
RPSvc: rpSvc,
} }
} }
@@ -94,6 +99,26 @@ type clientUpsertRequest struct {
Metadata *map[string]interface{} `json:"metadata"` 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 {
return false, nil
}
// Super Admin bypass
if profile.Role == domain.RoleSuperAdmin {
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
}
return allowed, nil
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error { func (h *DevHandler) ListClients(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
@@ -104,6 +129,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
offset = 0 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) clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil { if err != nil {
if errors.Is(err, service.ErrHydraNotFound) { if errors.Is(err, service.ErrHydraNotFound) {
@@ -120,7 +150,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients)) items := make([]clientSummary, 0, len(clients))
for _, client := range 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{ return c.JSON(clientListResponse{
@@ -145,6 +180,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
} }
summary := h.mapClientSummary(*client) 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{ return c.JSON(clientDetailResponse{
Client: summary, Client: summary,
Endpoints: clientEndpoints{ Endpoints: clientEndpoints{
@@ -175,6 +222,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) 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) updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status)
if err != nil { if err != nil {
if errors.Is(err, service.ErrHydraNotFound) { if errors.Is(err, service.ErrHydraNotFound) {
@@ -221,9 +280,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes()) grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes())
responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes()) responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes())
clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential"))) clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private")))
if clientType != "public" && clientType != "confidential" { if clientType != "pkce" && clientType != "private" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) 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"))) status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active")))
@@ -239,7 +309,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" { if tokenAuthMethod == "" {
if clientType == "public" { if clientType == "pkce" {
tokenAuthMethod = "none" tokenAuthMethod = "none"
} else { } else {
tokenAuthMethod = "client_secret_basic" tokenAuthMethod = "client_secret_basic"
@@ -310,8 +380,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
clientType := "" clientType := ""
if req.Type != nil { if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type)) clientType = strings.ToLower(strings.TrimSpace(*req.Type))
if clientType != "public" && clientType != "confidential" { if clientType != "pkce" && clientType != "private" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) 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 +407,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
if tokenAuthMethod == "" && clientType != "" { if tokenAuthMethod == "" && clientType != "" {
if clientType == "public" { if clientType == "pkce" {
tokenAuthMethod = "none" tokenAuthMethod = "none"
} else { } else {
tokenAuthMethod = "client_secret_basic" tokenAuthMethod = "client_secret_basic"
@@ -382,6 +464,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) 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 err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) { if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -517,14 +611,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) 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 // 1. Generate new secret
newSecret, err := generateRandomSecret(20) newSecret, err := generateRandomSecret(20)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"})
} }
// 2. Get current client to preserve other fields // 2. Get current client to preserve other fields (already fetched above)
current, err := h.Hydra.GetClient(c.Context(), clientID)
if err != nil { if err != nil {
if errors.Is(err, service.ErrHydraNotFound) { if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -584,9 +689,9 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
} }
} }
clientType := "confidential" clientType := "private"
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
clientType = "public" clientType = "pkce"
} }
name := strings.TrimSpace(client.ClientName) name := strings.TrimSpace(client.ClientName)

View File

@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,8 +12,32 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
) )
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
func TestListClients_Success(t *testing.T) { func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
@@ -23,13 +49,22 @@ func TestListClients_Success(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
}) })
mockKeto := new(MockKetoService)
// For simplicity, always allow in basic success test
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil)
h := &DevHandler{ h := &DevHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport}, HTTPClient: &http.Client{Transport: transport},
}, },
Keto: mockKeto,
} }
app := fiber.New() app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients", h.ListClients) app.Get("/api/v1/dev/clients", h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
@@ -58,14 +93,21 @@ func TestGetClient_Success(t *testing.T) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
}) })
mockKeto := new(MockKetoService)
h := &DevHandler{ h := &DevHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
PublicURL: "http://hydra-public.test", // PublicURL 추가 PublicURL: "http://hydra-public.test", // PublicURL 추가
HTTPClient: &http.Client{Transport: transport}, HTTPClient: &http.Client{Transport: transport},
}, },
Keto: mockKeto,
} }
app := fiber.New() app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/dev/clients/:id", h.GetClient) app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
@@ -80,26 +122,6 @@ func TestGetClient_Success(t *testing.T) {
assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization) assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization)
} }
func TestGetClient_NotFound(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil
})
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
}
app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestCreateClient_Success(t *testing.T) { func TestCreateClient_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost && r.URL.Path == "/clients" { if r.Method == http.MethodPost && r.URL.Path == "/clients" {
@@ -112,6 +134,7 @@ func TestCreateClient_Success(t *testing.T) {
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
}) })
mockKeto := new(MockKetoService)
secretRepo := &mockSecretRepo{secrets: make(map[string]string)} secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)}
@@ -122,13 +145,18 @@ func TestCreateClient_Success(t *testing.T) {
}, },
SecretRepo: secretRepo, SecretRepo: secretRepo,
Redis: redisRepo, Redis: redisRepo,
Keto: mockKeto,
} }
app := fiber.New() app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/dev/clients", h.CreateClient) app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"client_name": "New App", "client_name": "New App",
"type": "confidential", "type": "private",
"redirectUris": []string{"http://localhost/cb"}, "redirectUris": []string{"http://localhost/cb"},
}) })
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))

View File

@@ -48,7 +48,7 @@ test("clients page loads correctly", async ({ page }) => {
{ {
id: "client-playwright", id: "client-playwright",
name: "Playwright Client", name: "Playwright Client",
type: "confidential", type: "private",
status: "active", status: "active",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
redirectUris: ["http://localhost:5174/callback"], redirectUris: ["http://localhost:5174/callback"],