Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/controlplane/internal/service/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
152 changes: 152 additions & 0 deletions app/controlplane/internal/service/attestation_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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))
}
Loading