From 15acdf180af1b02788f4e54222b6c92311f1abc5 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Tue, 22 Sep 2026 16:49:36 +0200 Subject: [PATCH] fix(controlplane): confine legacy robot accounts to their bound workflow Robot accounts are 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 through GetContract, Init, Store and Cancel. Attestation endpoints skip the authz middleware and RBAC is disabled for robot accounts, leaving the handler as the only authorization point. The usercontext.RobotAccount carrier is shared with the API-token and federated middlewares, which leave WorkflowID empty, so the binding applies only when a workflow is actually encoded in the credential. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 119f621c-b024-493e-aa13-41acd7522345 --- .../internal/service/attestation.go | 9 ++ .../service/attestation_integration_test.go | 152 ++++++++++++++++++ 2 files changed, 161 insertions(+) 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)) +}