forked from baron/baron-sso
Dev API에 RP operator relation 조회/부여/회수 추가
This commit is contained in:
@@ -290,7 +290,7 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||
@@ -708,6 +708,9 @@ func main() {
|
||||
dev.Get("/clients", devHandler.ListClients)
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Get("/clients/:id/relations", devHandler.ListClientRelations)
|
||||
dev.Post("/clients/:id/relations", devHandler.AddClientRelation)
|
||||
dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache)
|
||||
dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache)
|
||||
|
||||
@@ -31,6 +31,7 @@ type DevHandler struct {
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
Auth interface {
|
||||
@@ -43,7 +44,9 @@ func NewDevHandler(
|
||||
secretRepo domain.ClientSecretRepository,
|
||||
consentRepo repository.ClientConsentRepository,
|
||||
rpSvc service.RelyingPartyService,
|
||||
keto service.KetoService, tenantSvc service.TenantService,
|
||||
keto service.KetoService,
|
||||
ketoOutbox repository.KetoOutboxRepository,
|
||||
tenantSvc service.TenantService,
|
||||
auth ...interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
},
|
||||
@@ -64,6 +67,7 @@ func NewDevHandler(
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: ketoOutbox,
|
||||
RPSvc: rpSvc,
|
||||
TenantSvc: tenantSvc,
|
||||
Auth: authProvider,
|
||||
@@ -118,6 +122,23 @@ type clientEndpoints struct {
|
||||
UserInfo string `json:"userinfo"`
|
||||
}
|
||||
|
||||
type clientRelationSummary struct {
|
||||
Relation string `json:"relation"`
|
||||
Subject string `json:"subject"`
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
}
|
||||
|
||||
type clientRelationListResponse struct {
|
||||
Items []clientRelationSummary `json:"items"`
|
||||
}
|
||||
|
||||
type clientRelationUpsertRequest struct {
|
||||
Relation string `json:"relation"`
|
||||
Subject string `json:"subject"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
type consentSummary struct {
|
||||
Subject string `json:"subject"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
@@ -160,6 +181,19 @@ var reservedSystemClientNames = map[string]string{
|
||||
"devfront": "devfront",
|
||||
}
|
||||
|
||||
var allowedRelyingPartyOperatorRelations = map[string]struct{}{
|
||||
"admins": {},
|
||||
"creator": {},
|
||||
"config_editor": {},
|
||||
"secret_rotator": {},
|
||||
"jwks_viewer": {},
|
||||
"jwks_operator": {},
|
||||
"consent_viewer": {},
|
||||
"consent_revoker": {},
|
||||
"relationship_viewer": {},
|
||||
"status_operator": {},
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
return domain.NormalizeRole(role)
|
||||
}
|
||||
@@ -314,6 +348,32 @@ func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.User
|
||||
return err == nil && allowed
|
||||
}
|
||||
|
||||
func (h *DevHandler) canViewClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if h.canOperateClientByPermit(c, profile, summary, "view_relationships") {
|
||||
return true
|
||||
}
|
||||
return canAccessClientByLegacyScope(profile, summary)
|
||||
}
|
||||
|
||||
func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
if h.canOperateClientByPermit(c, profile, summary, "manage") {
|
||||
return true
|
||||
}
|
||||
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if clientTenantID != "" && h.canManageTenantClientsByPermit(c, profile, clientTenantID) {
|
||||
return true
|
||||
}
|
||||
|
||||
return canAccessClientByLegacyScope(profile, summary)
|
||||
}
|
||||
|
||||
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
@@ -389,6 +449,79 @@ func reservedSystemClientOwnerID(name string) (string, bool) {
|
||||
return ownerID, ok
|
||||
}
|
||||
|
||||
func normalizeRelyingPartyRelation(relation string) string {
|
||||
return strings.TrimSpace(relation)
|
||||
}
|
||||
|
||||
func isAllowedRelyingPartyOperatorRelation(relation string) bool {
|
||||
_, ok := allowedRelyingPartyOperatorRelations[normalizeRelyingPartyRelation(relation)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeClientRelationSubject(subject, userID string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject != "" {
|
||||
return subject
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return ""
|
||||
}
|
||||
return "User:" + userID
|
||||
}
|
||||
|
||||
func parseClientRelationSubject(subject string) (string, string) {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(subject, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func validateClientRelationWriteInput(relation, subject string) error {
|
||||
relation = normalizeRelyingPartyRelation(relation)
|
||||
if !isAllowedRelyingPartyOperatorRelation(relation) {
|
||||
return fmt.Errorf("unsupported relation")
|
||||
}
|
||||
|
||||
subjectType, subjectID := parseClientRelationSubject(subject)
|
||||
if subjectType != "User" || subjectID == "" || strings.Contains(subjectID, "#") {
|
||||
return fmt.Errorf("subject must be in User:<id> format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapRelationTupleSummary(tuple service.RelationTuple) clientRelationSummary {
|
||||
subjectType, subjectID := parseClientRelationSubject(tuple.SubjectID)
|
||||
return clientRelationSummary{
|
||||
Relation: tuple.Relation,
|
||||
Subject: tuple.SubjectID,
|
||||
SubjectType: subjectType,
|
||||
SubjectID: subjectID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
return clientSummary{}, fmt.Errorf("client id is required")
|
||||
}
|
||||
client, err := h.Hydra.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return clientSummary{}, err
|
||||
}
|
||||
return h.mapClientSummary(*client), nil
|
||||
}
|
||||
|
||||
func (h *DevHandler) getRelationRequestProfile(c *fiber.Ctx) *domain.UserProfileResponse {
|
||||
return h.getCurrentProfile(c)
|
||||
}
|
||||
|
||||
func validateReservedSystemClientName(clientID, name string) error {
|
||||
ownerID, reserved := reservedSystemClientOwnerID(name)
|
||||
if !reserved {
|
||||
@@ -733,6 +866,130 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
profile := h.getRelationRequestProfile(c)
|
||||
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
|
||||
if !h.canViewClientRelations(c, profile, summary) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
if h.Keto == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable")
|
||||
}
|
||||
|
||||
items := make([]clientRelationSummary, 0)
|
||||
for relation := range allowedRelyingPartyOperatorRelations {
|
||||
tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, "")
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
for _, tuple := range tuples {
|
||||
items = append(items, mapRelationTupleSummary(tuple))
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(clientRelationListResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
var req clientRelationUpsertRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
req.Relation = normalizeRelyingPartyRelation(req.Relation)
|
||||
req.Subject = normalizeClientRelationSubject(req.Subject, req.UserID)
|
||||
if err := validateClientRelationWriteInput(req.Relation, req.Subject); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
profile := h.getRelationRequestProfile(c)
|
||||
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
if !h.canManageClientRelations(c, profile, summary) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
if h.Keto == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable")
|
||||
}
|
||||
if h.KetoOutbox == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable")
|
||||
}
|
||||
|
||||
existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, req.Relation, req.Subject)
|
||||
if err == nil && len(existing) > 0 {
|
||||
return errorJSON(c, fiber.StatusConflict, "relation already exists")
|
||||
}
|
||||
|
||||
if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: clientID,
|
||||
Relation: req.Relation,
|
||||
Subject: req.Subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{
|
||||
Object: clientID,
|
||||
Relation: req.Relation,
|
||||
SubjectID: req.Subject,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error {
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
relation := normalizeRelyingPartyRelation(c.Query("relation"))
|
||||
subject := normalizeClientRelationSubject(c.Query("subject"), c.Query("userId"))
|
||||
if err := validateClientRelationWriteInput(relation, subject); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
profile := h.getRelationRequestProfile(c)
|
||||
summary, err := h.loadClientSummary(c.Context(), clientID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
if !h.canManageClientRelations(c, profile, summary) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
if h.KetoOutbox == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable")
|
||||
}
|
||||
|
||||
if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: clientID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
}); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
clientID := c.Params("id")
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Mocks with Unique Names to Avoid Collisions ---
|
||||
@@ -51,6 +52,31 @@ type devMockRedisRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
type devMockKetoOutboxRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||
return m.Called(ctx, entry).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
|
||||
return m.Called(tx, entry).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
|
||||
args := m.Called(ctx, limit)
|
||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||
return m.Called(ctx, id).Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockRedisRepo) Set(key, value string, exp time.Duration) error {
|
||||
if m.data == nil {
|
||||
m.data = make(map[string]string)
|
||||
@@ -1223,3 +1249,175 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||
assert.Len(t, result.Items, 1)
|
||||
assert.Equal(t, "evt-1", result.Items[0].EventID)
|
||||
}
|
||||
|
||||
func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
||||
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
||||
}, nil)
|
||||
for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil)
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result clientRelationListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Items, 1)
|
||||
assert.Equal(t, "config_editor", result.Items[0].Relation)
|
||||
assert.Equal(t, "User", result.Items[0].SubjectType)
|
||||
assert.Equal(t, "user-2", result.Items[0].SubjectID)
|
||||
}
|
||||
|
||||
func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "config_editor" &&
|
||||
entry.Subject == "User:user-2" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"relation": "config_editor",
|
||||
"userId": "user-2",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
return entry.Namespace == "RelyingParty" &&
|
||||
entry.Object == "client-1" &&
|
||||
entry.Relation == "config_editor" &&
|
||||
entry.Subject == "User:user-2" &&
|
||||
entry.Action == domain.KetoOutboxActionDelete
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import LoginPage from "../features/auth/LoginPage";
|
||||
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
|
||||
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
|
||||
@@ -33,6 +34,7 @@ export const router = createBrowserRouter(
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
|
||||
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id/relationships", element: <ClientRelationsPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "profile", element: <ProfilePage /> },
|
||||
],
|
||||
|
||||
@@ -230,6 +230,12 @@ function ClientConsentsPage() {
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/relationships`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -272,6 +272,12 @@ function ClientDetailsPage() {
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/relationships`}
|
||||
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -680,6 +680,12 @@ function ClientGeneralPage() {
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/relationships`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</span>
|
||||
|
||||
423
devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
423
devfront/src/features/clients/ClientRelationsPage.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
addClientRelation,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
removeClientRelation,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const relationOptions = [
|
||||
"admins",
|
||||
"creator",
|
||||
"config_editor",
|
||||
"secret_rotator",
|
||||
"jwks_viewer",
|
||||
"jwks_operator",
|
||||
"consent_viewer",
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"status_operator",
|
||||
] as const;
|
||||
|
||||
function ClientRelationsPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id ?? "";
|
||||
const [relation, setRelation] = useState<(typeof relationOptions)[number]>(
|
||||
"config_editor",
|
||||
);
|
||||
const [userId, setUserId] = useState("");
|
||||
|
||||
const { data: clientData } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: relationData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["client-relations", clientId],
|
||||
queryFn: () => fetchClientRelations(clientId),
|
||||
enabled: clientId.length > 0,
|
||||
});
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...(relationData?.items ?? [])].sort((a, b) => {
|
||||
const relationCompare = a.relation.localeCompare(b.relation);
|
||||
if (relationCompare !== 0) {
|
||||
return relationCompare;
|
||||
}
|
||||
return a.subject.localeCompare(b.subject);
|
||||
});
|
||||
}, [relationData?.items]);
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
addClientRelation(clientId, {
|
||||
relation,
|
||||
userId: userId.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] });
|
||||
setUserId("");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.added",
|
||||
"Relationship가 추가되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.add_error",
|
||||
"Relationship 추가 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (payload: { relation: string; subject: string }) =>
|
||||
removeClientRelation(clientId, payload.relation, payload.subject),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] });
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.removed",
|
||||
"Relationship가 제거되었습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_error",
|
||||
"Relationship 제거 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error).message,
|
||||
},
|
||||
),
|
||||
"error",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!userId.trim()) {
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.relationships.user_required",
|
||||
"추가할 User ID를 입력하세요.",
|
||||
),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRemove = (targetRelation: string, subject: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.relationships.remove_confirm",
|
||||
"이 relationship를 제거하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMutation.mutate({ relation: targetRelation, subject });
|
||||
}
|
||||
};
|
||||
|
||||
if (!clientId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-4">
|
||||
<div className="flex flex-wrap justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link to="/" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-primary">
|
||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span>{clientData?.client?.name || clientId}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.details.tab.relationships",
|
||||
"Relationships",
|
||||
)}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to={`/clients/${clientId}`}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-3xl font-black leading-tight">
|
||||
{t(
|
||||
"ui.dev.clients.relationships.title",
|
||||
"Client Relationships",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.subtitle",
|
||||
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
clientData?.client?.status === "active" ? "info" : "muted"
|
||||
}
|
||||
>
|
||||
{clientData?.client?.status === "active"
|
||||
? t("ui.common.status.active", "Active")
|
||||
: t("ui.common.status.inactive", "Inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
<Link
|
||||
to={`/clients/${clientId}`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/consents`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/clients/${clientId}/settings`}
|
||||
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("ui.dev.clients.details.tab.settings", "Settings")}
|
||||
</Link>
|
||||
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
|
||||
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("ui.dev.clients.relationships.add_title", "Add Relationship")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.add_description",
|
||||
"현재는 direct User assignment만 지원합니다. subject는 자동으로 User:<id> 형식으로 전송됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relation-select">
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</Label>
|
||||
<select
|
||||
id="relation-select"
|
||||
value={relation}
|
||||
onChange={(e) =>
|
||||
setRelation(e.target.value as (typeof relationOptions)[number])
|
||||
}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{relationOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-id-input">
|
||||
{t("ui.dev.clients.relationships.user_id", "User ID")}
|
||||
</Label>
|
||||
<Input
|
||||
id="user-id-input"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.relationships.user_id_placeholder",
|
||||
"kratos user id",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{addMutation.isPending
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.clients.relationships.add", "Add")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
{t(
|
||||
"ui.dev.clients.relationships.list_title",
|
||||
"Assigned Relationships",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.relationships.list_description",
|
||||
"현재 RP에 직접 부여된 operator relation 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.load_error",
|
||||
"Relationship 조회 실패: {{error}}",
|
||||
{
|
||||
error:
|
||||
(error as AxiosError<{ error?: string }>).response?.data
|
||||
?.error ?? (error as Error).message,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.loading",
|
||||
"Relationship를 불러오는 중입니다...",
|
||||
)}
|
||||
</div>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.relationships.empty",
|
||||
"직접 부여된 relationship가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.relation", "Relation")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject", "Subject")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.relationships.subject_type", "Type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedItems.map((item) => (
|
||||
<TableRow key={`${item.relation}:${item.subject}`}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{item.relation}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-mono text-xs">{item.subject}</div>
|
||||
{item.subjectId && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
ID: {item.subjectId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.subjectType || "-"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-destructive hover:text-destructive"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() =>
|
||||
handleRemove(item.relation, item.subject)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("ui.common.delete", "Delete")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientRelationsPage;
|
||||
@@ -99,6 +99,23 @@ export type ClientUpsertRequest = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ClientRelation = {
|
||||
relation: string;
|
||||
subject: string;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
};
|
||||
|
||||
export type ClientRelationListResponse = {
|
||||
items: ClientRelation[];
|
||||
};
|
||||
|
||||
export type ClientRelationUpsertRequest = {
|
||||
relation: string;
|
||||
subject?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type ConsentSummary = {
|
||||
subject: string;
|
||||
userName?: string;
|
||||
@@ -164,6 +181,34 @@ export async function fetchClient(clientId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClientRelations(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientRelationListResponse>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function addClientRelation(
|
||||
clientId: string,
|
||||
payload: ClientRelationUpsertRequest,
|
||||
) {
|
||||
const { data } = await apiClient.post<ClientRelation>(
|
||||
`/dev/clients/${clientId}/relations`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeClientRelation(
|
||||
clientId: string,
|
||||
relation: string,
|
||||
subject: string,
|
||||
) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}/relations`, {
|
||||
params: { relation, subject },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClientStatus(
|
||||
clientId: string,
|
||||
status: ClientStatus,
|
||||
|
||||
Reference in New Issue
Block a user