diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 348e0aa1..507dacf9 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -3295,8 +3295,8 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID return } subject := "User:" + strings.TrimSpace(userID) - for _, relation := range []string{"view_dev_console", "grant_dev_permissions"} { - if !h.hasDirectTenantRelation(c, tenantID, relation, subject) { + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + if h.hasDirectTenantRelation(c, tenantID, relation, subject) { continue } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ @@ -3304,19 +3304,14 @@ func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID Object: tenantID, Relation: relation, Subject: subject, - Action: domain.KetoOutboxActionDelete, + Action: domain.KetoOutboxActionCreate, }) + if h.Keto != nil { + if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil { + slog.Warn("failed to grant immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err) + } + } } - 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) { @@ -3332,6 +3327,11 @@ func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID Subject: subject, Action: domain.KetoOutboxActionDelete, }) + if h.Keto != nil { + if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil { + slog.Warn("failed to revoke immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err) + } + } } } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 3c5b632f..4d630316 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1102,6 +1102,103 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) { mockOutbox.AssertExpectations(t) } +func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) { + mockKeto := new(devMockKetoService) + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once() + mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once() + } + + mockOutbox := new(devMockKetoOutboxRepository) + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + expectedRelation := relation + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "Tenant" && + entry.Object == "tenant-a" && + entry.Relation == expectedRelation && + entry.Subject == "User:user-1" && + entry.Action == domain.KetoOutboxActionCreate + })).Return(nil).Once() + } + + h := &DevHandler{ + Keto: mockKeto, + KetoOutbox: mockOutbox, + } + + app := fiber.New() + app.Get("/test", func(c *fiber.Ctx) error { + h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a") + return c.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + mockKeto.AssertExpectations(t) + mockOutbox.AssertExpectations(t) +} + +func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) { + mockKeto := new(devMockKetoService) + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1"). + Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once() + } + + h := &DevHandler{ + Keto: mockKeto, + KetoOutbox: new(devMockKetoOutboxRepository), + } + + app := fiber.New() + app.Get("/test", func(c *fiber.Ctx) error { + h.ensureDeveloperGrantRelation(c, "user-1", "tenant-a") + return c.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + +func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) { + mockKeto := new(devMockKetoService) + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once() + } + + mockOutbox := new(devMockKetoOutboxRepository) + for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { + expectedRelation := relation + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "Tenant" && + entry.Object == "tenant-a" && + entry.Relation == expectedRelation && + entry.Subject == "User:user-1" && + entry.Action == domain.KetoOutboxActionDelete + })).Return(nil).Once() + } + + h := &DevHandler{ + Keto: mockKeto, + KetoOutbox: mockOutbox, + } + + app := fiber.New() + app.Get("/test", func(c *fiber.Ctx) error { + h.revokeDeveloperGrantRelation(c, "user-1", "tenant-a") + return c.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, 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" {