diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 90ce3efd6..024dc5bdd 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -743,6 +743,15 @@ func (s *AttestationService) findWorkflowFromTokenOrNameOrRunID(ctx context.Cont } } + // Legacy robot accounts are pinned to a single workflow, so they may only operate on that one. + // The API-token and federated middlewares reuse the same carrier with an empty WorkflowID, so + // only a populated one identifies a robot account and constrains the caller. + if robotAccount := usercontext.CurrentRobotAccount(ctx); robotAccount != nil && robotAccount.WorkflowID != "" { + if wf.ID.String() != robotAccount.WorkflowID { + return nil, errors.Forbidden("forbidden", "robot account is scoped to a different workflow") + } + } + return wf, nil } diff --git a/app/controlplane/internal/service/attestation_integration_test.go b/app/controlplane/internal/service/attestation_integration_test.go index d57866c06..1e4cba899 100644 --- a/app/controlplane/internal/service/attestation_integration_test.go +++ b/app/controlplane/internal/service/attestation_integration_test.go @@ -27,6 +27,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/google/uuid" "github.com/stretchr/testify/suite" ) @@ -178,3 +179,154 @@ func (s *getContractRBACIntegrationSuite) ctxForToken(token *biz.APIToken) conte func TestGetContractRBACIntegration(t *testing.T) { suite.Run(t, new(getContractRBACIntegrationSuite)) } + +// Regression tests for the legacy robot-account credential: a robot account is scoped to a single +// workflow, but findWorkflowFromTokenOrNameOrRunID resolved the workflow purely from the request, +// so any robot account could reach every workflow in its organization. Robot accounts can no longer +// be issued (CP-N4) and there is no revoke path left in the product, so existing tokens must be +// confined by the handler. +// +// usercontext.RobotAccount is a carrier shared with the API-token and federated middlewares, which +// leave WorkflowID empty; only legacy robot accounts carry one, and those callers must be untouched. +type robotAccountWorkflowBindingIntegrationSuite struct { + testhelpers.UseCasesEachTestSuite + org *biz.Organization + projectA, projectB *biz.Project + workflowA, workflowB *biz.Workflow + orgToken *biz.APIToken + svc *AttestationService +} + +func (s *robotAccountWorkflowBindingIntegrationSuite) SetupTest() { + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + + ctx := context.Background() + var err error + + s.org, err = s.Organization.Create(ctx, "robot-account-binding-org") + s.Require().NoError(err) + + s.projectA, err = s.Project.Create(ctx, s.org.ID, "project-a") + s.Require().NoError(err) + s.projectB, err = s.Project.Create(ctx, s.org.ID, "project-b") + s.Require().NoError(err) + + s.workflowA, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "workflow-a", OrgID: s.org.ID, Project: "project-a"}) + s.Require().NoError(err) + s.workflowB, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: "workflow-b", OrgID: s.org.ID, Project: "project-b"}) + s.Require().NoError(err) + + s.orgToken, err = s.APIToken.Create(ctx, "token-org", nil, nil, &s.org.ID) + s.Require().NoError(err) + + authzUC := biz.NewAuthzUseCase(&biz.AuthzUseCaseConfig{ + CasbinEnforcer: s.Enforcer, + APITokenRepo: s.Repos.APITokenRepo, + Logger: s.L, + }) + + s.svc = NewAttestationService(&NewAttestationServiceOpts{ + WorkflowRunUC: s.WorkflowRun, + WorkflowUC: s.Workflow, + WorkflowContractUC: s.WorkflowContract, + OrgUC: s.Organization, + ProjectUC: s.Project, + ProjectVersionUC: s.ProjectVersion, + Opts: []NewOpt{WithProjectUseCase(s.Project), WithEnforcer(authzUC)}, + }) +} + +func (s *robotAccountWorkflowBindingIntegrationSuite) TestGetContractWorkflowBinding() { + testCases := []struct { + name string + // ctx is built per-case because the credential shape is what is under test. + ctx func() context.Context + // target workflow of the request + wantWorkflow *biz.Workflow + wantForbidden bool + }{ + { + name: "legacy robot account reaches the workflow it is bound to", + ctx: func() context.Context { return s.ctxForRobotAccount(s.workflowA) }, + wantWorkflow: s.workflowA, + }, + { + name: "legacy robot account cannot reach another workflow in the same org", + ctx: func() context.Context { return s.ctxForRobotAccount(s.workflowA) }, + wantWorkflow: s.workflowB, + wantForbidden: true, + }, + { + name: "org-scoped API token is unaffected by the binding", + ctx: func() context.Context { return s.ctxForOrgAPIToken() }, + wantWorkflow: s.workflowB, + }, + { + name: "federated credential is unaffected by the binding", + ctx: func() context.Context { return s.ctxForFederated() }, + wantWorkflow: s.workflowB, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + resp, err := s.svc.GetContract(tc.ctx(), &pb.AttestationServiceGetContractRequest{ + ProjectName: tc.wantWorkflow.Project, + WorkflowName: tc.wantWorkflow.Name, + }) + + if tc.wantForbidden { + s.Require().Error(err) + s.True(kerrors.IsForbidden(err), "expected forbidden, got %v", err) + return + } + + s.Require().NoError(err) + s.Equal(tc.wantWorkflow.Name, resp.GetResult().GetWorkflow().GetName()) + s.NotNil(resp.GetResult().GetContract()) + }) + } +} + +// ctxForRobotAccount mirrors the context WithAttestationContextFromRobotAccount produces for a +// legacy robot-account token: the account is pinned to exactly one workflow. +func (s *robotAccountWorkflowBindingIntegrationSuite) ctxForRobotAccount(wf *biz.Workflow) context.Context { + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + + return usercontext.WithRobotAccount(ctx, &usercontext.RobotAccount{ + ID: uuid.NewString(), + WorkflowID: wf.ID.String(), + OrgID: s.org.ID, + ProviderKey: attjwtmiddleware.RobotAccountProviderKey, + }) +} + +// ctxForOrgAPIToken mirrors the API-token middleware: it reuses the RobotAccount carrier but +// leaves WorkflowID empty. +func (s *robotAccountWorkflowBindingIntegrationSuite) ctxForOrgAPIToken() context.Context { + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ + ID: s.orgToken.ID.String(), + Name: s.orgToken.Name, + }) + ctx = usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: s.orgToken.ID.String()}).String()) + + return usercontext.WithRobotAccount(ctx, &usercontext.RobotAccount{ + OrgID: s.org.ID, + ProviderKey: attjwtmiddleware.APITokenProviderKey, + }) +} + +// ctxForFederated mirrors the federated middleware, which also leaves WorkflowID empty. +func (s *robotAccountWorkflowBindingIntegrationSuite) ctxForFederated() context.Context { + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + + return usercontext.WithRobotAccount(ctx, &usercontext.RobotAccount{ + OrgID: s.org.ID, + ProviderKey: attjwtmiddleware.FederatedProviderKey, + }) +} + +func TestRobotAccountWorkflowBindingIntegration(t *testing.T) { + suite.Run(t, new(robotAccountWorkflowBindingIntegrationSuite)) +}