From 09f3190b95786d94cd5b7cd95877b57dfac5212b Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Wed, 18 Feb 2026 12:25:03 +0100 Subject: [PATCH] fix: resolve contract scope by project name instead of UUID When creating a workflow with a project-scoped contract, the scope check compared UUIDs directly, which broke when the project was soft-deleted and recreated (same name, different UUID). Fix by looking up the scoped project by its stored UUID and comparing project names instead. Also add comprehensive test coverage for project-scoped contracts, including the soft-delete-and-recreate scenario. Fixes #2755 Signed-off-by: Miguel Martinez --- .../pkg/biz/workflow_integration_test.go | 61 +++++++++++++++++++ app/controlplane/pkg/data/workflow.go | 17 ++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/app/controlplane/pkg/biz/workflow_integration_test.go b/app/controlplane/pkg/biz/workflow_integration_test.go index 1e8937c3e..467da0187 100644 --- a/app/controlplane/pkg/biz/workflow_integration_test.go +++ b/app/controlplane/pkg/biz/workflow_integration_test.go @@ -215,6 +215,67 @@ func (s *workflowIntegrationTestSuite) TestCreate() { } } +// TestCreateWithProjectScopedContract verifies that a project-scoped contract can be used +// when creating a workflow in the same project, and is rejected for different projects. +func (s *workflowIntegrationTestSuite) TestCreateWithProjectScopedContract() { + ctx := context.Background() + const projectName = "scoped-project" + + // Create the project by creating a workflow in it first + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, Name: "initial-wf", Project: projectName, + }) + s.Require().NoError(err) + + // Create a contract scoped to this project + contract, err := s.WorkflowContract.Create(ctx, &biz.WorkflowContractCreateOpts{ + OrgID: s.org.ID, + Name: "scoped-contract", + ProjectID: &wf.ProjectID, + }) + s.Require().NoError(err) + s.Require().NotNil(contract) + + s.Run("succeeds when project name matches the contract scope", func() { + _, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, + Name: "new-wf", + Project: projectName, + ContractName: contract.Name, + }) + s.NoError(err) + }) + + s.Run("fails when creating workflow in a different project", func() { + _, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, + Name: "other-wf", + Project: "other-project", + ContractName: contract.Name, + }) + s.ErrorContains(err, "scoped to a different project") + }) + + s.Run("succeeds after the original project is soft-deleted and a new one with the same name is created", func() { + // Simulate the bug: soft-delete the original project so the next workflow creation + // creates a new project with the same name but a different UUID. + err := s.Data.DB.Project.UpdateOneID(wf.ProjectID).SetDeletedAt(time.Now()).Exec(ctx) + s.Require().NoError(err) + + // Creating the workflow triggers a project upsert that creates a new project (new UUID) + // because the old one is soft-deleted and doesn't conflict on the partial index. + // The contract still references the old project UUID, so the scope check must compare + // by name to allow this. + _, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, + Name: "recovered-wf", + Project: projectName, + ContractName: contract.Name, + }) + s.NoError(err) + }) +} + func (s *workflowIntegrationTestSuite) TestUpdate() { ctx := context.Background() const ( diff --git a/app/controlplane/pkg/data/workflow.go b/app/controlplane/pkg/data/workflow.go index 9f7b7573c..f548c8fb5 100644 --- a/app/controlplane/pkg/data/workflow.go +++ b/app/controlplane/pkg/data/workflow.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -179,9 +179,18 @@ func (r *WorkflowRepo) Create(ctx context.Context, opts *biz.WorkflowCreateOpts) return err } - // Fail if it's scoped to a different project - if existingContract.ScopedResourceID != uuid.Nil && existingContract.ScopedResourceID != projectID && existingContract.ScopedResourceType == biz.ContractScopeProject { - return biz.NewErrUnauthorizedStr(fmt.Sprintf("contract %q is scoped to a different project", opts.ContractName)) + // Fail if it's scoped to a different project. + // We resolve the scoped project by UUID (including soft-deleted ones) to read its name, + // then compare that name against the target project. This handles the case where the + // original project was soft-deleted and recreated with the same name but a new UUID — + // the contract is still valid for a project with that name. + if existingContract.ScopedResourceID != uuid.Nil && existingContract.ScopedResourceType == biz.ContractScopeProject { + scopedProject, err := tx.Project.Query(). + Where(project.ID(existingContract.ScopedResourceID)). + Only(ctx) + if err != nil || scopedProject.Name != opts.Project { + return biz.NewErrUnauthorizedStr(fmt.Sprintf("contract %q is scoped to a different project", opts.ContractName)) + } } }