forked from baron/baron-sso
개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선
This commit is contained in:
@@ -349,21 +349,47 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
||||
if profile == nil {
|
||||
return false
|
||||
}
|
||||
if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin {
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if clientTenantID != "" {
|
||||
if role != domain.RoleUser && clientTenantID != "" {
|
||||
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if role == domain.RoleUser {
|
||||
return false
|
||||
}
|
||||
|
||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
||||
return err == nil && allowed
|
||||
}
|
||||
|
||||
func (h *DevHandler) hasDirectRelyingPartyOperatorRelation(c *fiber.Ctx, profile *domain.UserProfileResponse, clientID string) bool {
|
||||
if h.Keto == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
subject := ketoSubjectFromProfile(profile)
|
||||
if subject == "" || strings.TrimSpace(clientID) == "" {
|
||||
return false
|
||||
}
|
||||
for relation := range allowedRelyingPartyOperatorRelations {
|
||||
tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, subject)
|
||||
if err == nil && len(tuples) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
|
||||
if strings.TrimSpace(tenantID) == "" {
|
||||
return false
|
||||
@@ -1428,7 +1454,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = *profile.TenantID
|
||||
}
|
||||
if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||
if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
|
||||
}
|
||||
|
||||
@@ -1473,7 +1499,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
}
|
||||
if !isAppManager {
|
||||
if !isAppManager && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client")
|
||||
}
|
||||
}
|
||||
@@ -1557,11 +1583,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Automatically grant admin permission to the creator in Keto
|
||||
if h.KetoOutbox != nil && profile != nil {
|
||||
subject := "User:" + profile.ID
|
||||
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "RelyingParty",
|
||||
Object: created.ClientID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + profile.ID,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1569,6 +1596,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
} else {
|
||||
slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID)
|
||||
}
|
||||
if h.Keto != nil {
|
||||
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
|
||||
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret in metadata for later retrieval
|
||||
@@ -2863,10 +2895,66 @@ func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error {
|
||||
if status == nil {
|
||||
return c.JSON(fiber.Map{"status": "none"})
|
||||
}
|
||||
if status.Status == domain.DeveloperRequestStatusApproved {
|
||||
h.ensureDeveloperGrantRelation(c, status.UserID, status.TenantID)
|
||||
}
|
||||
|
||||
return c.JSON(status)
|
||||
}
|
||||
|
||||
func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
|
||||
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
|
||||
return
|
||||
}
|
||||
subject := "User:" + strings.TrimSpace(userID)
|
||||
for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} {
|
||||
if !h.hasDirectTenantRelation(c, tenantID, relation, subject) {
|
||||
continue
|
||||
}
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
if h.hasDirectTenantRelation(c, tenantID, "developer_console_grant_manager", subject) {
|
||||
return
|
||||
}
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "developer_console_grant_manager",
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) {
|
||||
if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" {
|
||||
return
|
||||
}
|
||||
subject := "User:" + strings.TrimSpace(userID)
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) hasDirectTenantRelation(c *fiber.Ctx, tenantID, relation, subject string) bool {
|
||||
if h.Keto == nil {
|
||||
return false
|
||||
}
|
||||
tuples, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, relation, subject)
|
||||
return err == nil && len(tuples) > 0
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
@@ -2923,18 +3011,7 @@ func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error {
|
||||
|
||||
// Grant Keto Permissions
|
||||
if h.KetoOutbox != nil {
|
||||
subject := "User:" + devReq.UserID
|
||||
permissions := []string{"view_dev_console", "grant_dev_permissions"}
|
||||
|
||||
for _, relation := range permissions {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: devReq.TenantID,
|
||||
Relation: relation,
|
||||
Subject: subject,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
@@ -2968,3 +3045,42 @@ func (h *DevHandler) RejectDeveloperRequest(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *DevHandler) CancelDeveloperRequestApproval(c *fiber.Ctx) error {
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||
}
|
||||
if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request id")
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
}
|
||||
if err := c.BodyParser(&reqBody); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id))
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details")
|
||||
}
|
||||
if devReq.Status != domain.DeveloperRequestStatusApproved {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "only approved requests can be cancelled")
|
||||
}
|
||||
|
||||
if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID)
|
||||
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,68 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
body["client_secret"] = "generated-secret"
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(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 == "admins" &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||
Keto: mockKeto,
|
||||
KetoOutbox: mockOutbox,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-1",
|
||||
"name": "App One",
|
||||
"type": "private",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetStats_Success(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
|
||||
Reference in New Issue
Block a user