diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index fbedf4378..38554729f 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -31,7 +31,10 @@ func newReferrerDiscoverCmd() *cobra.Command { cmd := &cobra.Command{ Use: "discover", Short: "(Preview) inspect pieces of evidence or artifacts stored through Chainloop", - RunE: func(cmd *cobra.Command, args []string) error { + Long: `(Preview) inspect pieces of evidence or artifacts stored through Chainloop. + +The discovery covers only the current organization. Select another organization with --org.`, + RunE: func(_ *cobra.Command, _ []string) error { pagination := &action.PaginationOpts{ Limit: paginationOpts.Limit, NextCursor: paginationOpts.NextCursor, diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 875069029..598681229 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -1404,6 +1404,12 @@ Options inherited from parent commands (Preview) inspect pieces of evidence or artifacts stored through Chainloop +Synopsis + +(Preview) inspect pieces of evidence or artifacts stored through Chainloop. + +The discovery covers only the current organization. Select another organization with --org. + ``` chainloop discover [flags] ``` diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 160ea580b..48066e391 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -318,9 +318,9 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01:B\x92A?\n" + - "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\x90\x03\n" + - "\x0fReferrerService\x12\xa9\x02\n" + - "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\xa4\x01\x92A\x86\x01\x12\x19Discover private referrer\x1aWReturns the referrer item for a given digest in the organizations of the logged-in user:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x1aQ\x92AN\n" + + "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\x80\x03\n" + + "\x0fReferrerService\x12\x99\x02\n" + + "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\x94\x01\x92Aw\x12\x19Discover private referrer\x1aHReturns the referrer item for a given digest in the current organization:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x1aQ\x92AN\n" + "\x0fReferrerService\x12;Referrer service for discovering referred content by digestBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" var ( diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index ce5be09f9..d6d40d3ce 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -26,12 +26,12 @@ import "protoc-gen-openapiv2/options/annotations.proto"; option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1"; service ReferrerService { - // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user + // DiscoverPrivate returns the referrer item for a given digest in the current organization rpc DiscoverPrivate(ReferrerServiceDiscoverPrivateRequest) returns (ReferrerServiceDiscoverPrivateResponse) { option (google.api.http) = {get: "/discover/{digest}"}; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Discover private referrer" - description: "Returns the referrer item for a given digest in the organizations of the logged-in user" + description: "Returns the referrer item for a given digest in the current organization" produces: ["application/json"] }; } diff --git a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go index 62856be96..edbc016b1 100644 --- a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go @@ -41,7 +41,7 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ReferrerServiceClient interface { - // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user + // DiscoverPrivate returns the referrer item for a given digest in the current organization DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) } @@ -66,7 +66,7 @@ func (c *referrerServiceClient) DiscoverPrivate(ctx context.Context, in *Referre // All implementations must embed UnimplementedReferrerServiceServer // for forward compatibility type ReferrerServiceServer interface { - // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user + // DiscoverPrivate returns the referrer item for a given digest in the current organization DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) mustEmbedUnimplementedReferrerServiceServer() } diff --git a/app/controlplane/api/controlplane/v1/referrer_http.pb.go b/app/controlplane/api/controlplane/v1/referrer_http.pb.go index 24f71246a..ff6c96171 100644 --- a/app/controlplane/api/controlplane/v1/referrer_http.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_http.pb.go @@ -22,7 +22,7 @@ const _ = http.SupportPackageIsVersion1 const OperationReferrerServiceDiscoverPrivate = "/controlplane.v1.ReferrerService/DiscoverPrivate" type ReferrerServiceHTTPServer interface { - // DiscoverPrivate DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user + // DiscoverPrivate DiscoverPrivate returns the referrer item for a given digest in the current organization DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) } diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 270c7e357..84017d221 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -589,7 +589,7 @@ export const ReferrerItem_AnnotationsEntry = { }; export interface ReferrerService { - /** DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user */ + /** DiscoverPrivate returns the referrer item for a given digest in the current organization */ DiscoverPrivate( request: DeepPartial, metadata?: grpc.Metadata, diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index f0c9ab662..ce80540cb 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -24,9 +24,7 @@ externalDocs: paths: /discover/{digest}: get: - description: >- - Returns the referrer item for a given digest in the organizations of the - logged-in user + description: Returns the referrer item for a given digest in the current organization operationId: ReferrerService_DiscoverPrivate parameters: - description: Digest is the unique identifier of the referrer to discover diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 107bca445..3f8ef65d3 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -41,7 +41,7 @@ func NewReferrerService(uc *biz.ReferrerUseCase, opts ...NewOpt) *ReferrerServic } func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerServiceDiscoverPrivateRequest) (*pb.ReferrerServiceDiscoverPrivateResponse, error) { - currentUser, currentToken, err := requireCurrentUserOrAPIToken(ctx) + currentUser, _, err := requireCurrentUserOrAPIToken(ctx) if err != nil { return nil, err } @@ -64,27 +64,20 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS extraFilters = append(extraFilters, biz.WithProjectScope(req.GetProjectName(), req.GetProjectVersion())) } - // if we are logged in as user we find the referrer from the user - // otherwise for the current organization associated with the API token + orgUUID, err := uuid.Parse(currentOrg.ID) + if err != nil { + return nil, fmt.Errorf("invalid org UUID: %w", err) + } + + // Discovery is always limited to the current organization, with the project visibility of + // the caller in it (see GetFromRootUser for users) var referrer *biz.StoredReferrer var nextCursor string if currentUser != nil { - referrer, nextCursor, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID, paginationOpts, extraFilters...) - } else if currentToken != nil { - var orgUUID uuid.UUID - orgUUID, err = uuid.Parse(currentOrg.ID) - if err != nil { - return nil, fmt.Errorf("invalid org UUID: %w", err) - } - - // Apply RBAC visibility for api tokens (only for the current organization) - orgsProjectsMap := make(map[uuid.UUID][]uuid.UUID) - visibleProjects := s.visibleProjects(ctx) - if visibleProjects != nil { - orgsProjectsMap[orgUUID] = visibleProjects - } - - referrer, nextCursor, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, orgsProjectsMap, paginationOpts, extraFilters...) + referrer, nextCursor, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID, orgUUID, paginationOpts, extraFilters...) + } else { + referrer, nextCursor, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, + s.rbacScopesForOrg(ctx, orgUUID).ProjectIDsByOrg(), paginationOpts, extraFilters...) } if err != nil { return nil, handleUseCaseErr(ctx, err, s.log) diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 06bbfb019..e3481c03e 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -161,9 +161,10 @@ func (s *ReferrerUseCase) ExtractAndPersist(ctx context.Context, att *dsse.Envel } // GetFromRootUser returns the referrer identified by the provided content digest, including its first-level references. -// For example if sha:deadbeef represents an attestation, the result will contain the attestation + materials associated to it. -// It only returns referrers that belong to organizations the user is member of. -func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, userID string, p *pagination.CursorOptions, extraFilters ...GetFromRootFilter) (*StoredReferrer, string, error) { +// The discovery is limited to the given organization, with the project visibility that the user has in that +// organization. The caller makes sure that the user can work in the organization, as for any other operation. +// A user without a membership in it (an instance admin) sees the whole organization. +func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, userID string, orgID uuid.UUID, p *pagination.CursorOptions, extraFilters ...GetFromRootFilter) (*StoredReferrer, string, error) { ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.GetFromRootUser") defer span.End() @@ -172,17 +173,25 @@ func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, return nil, "", NewErrInvalidUUID(err) } - userOrgs, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) + _, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) if err != nil { return nil, "", err } - // We pass the list of organizationsIDs from where to look for the referrer: - // the organizations the user is a member of. Referrers are always project-scoped, so only the - // project dimension of the RBAC scopes is relevant here. - return s.GetFromRoot(ctx, digest, rootKind, userOrgs, scopes.ProjectIDsByOrg(), p, extraFilters...) + // Only the project visibility of the user in this organization applies. A referrer lists its + // projects of all organizations, so a visible project in another organization must not count. + // Referrers are always project-scoped, so only the project dimension of the RBAC scopes is relevant. + var projectIDs map[OrgID][]ProjectID + if scope, ok := scopes[orgID]; ok { + projectIDs = RBACScopes{orgID: scope}.ProjectIDsByOrg() + } + + return s.GetFromRoot(ctx, digest, rootKind, []uuid.UUID{orgID}, projectIDs, p, extraFilters...) } +// GetFromRoot returns the referrer identified by the provided content digest, including its first-level references. +// For example if sha:deadbeef represents an attestation, the result will contain the attestation + materials associated to it. +// It only returns referrers that belong to the given organizations, with the given project visibility. func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, p *pagination.CursorOptions, extraFilters ...GetFromRootFilter) (*StoredReferrer, string, error) { ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.GetFromRoot") defer span.End() diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 355413601..802e649c1 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -23,6 +23,7 @@ import ( "testing" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" @@ -59,7 +60,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsDependentAttestatio err = s.Referrer.ExtractAndPersist(ctx, envelope, wantReferrerAtt, s.workflow1.ID.String()) s.NoError(err) - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.String(), "ATTESTATION", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerAtt.String(), "ATTESTATION", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) // It has a commit and an attestation require.Len(s.T(), got.References, 2) @@ -88,7 +89,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsConcurrency() { } wg.Wait() - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Len(got.WorkflowIDs, 2) s.Equal([]uuid.UUID{s.workflow2.ID, s.workflow1.ID}, got.WorkflowIDs) @@ -151,21 +152,21 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("it can store properly the first time", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) s.NoError(err) - prevStoredRef, _, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + prevStoredRef, _, err = s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) }) s.T().Run("and it's idempotent", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) s.NoError(err) - ref, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + ref, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) // Check it's the same referrer than previously retrieved, including timestamps s.Equal(prevStoredRef, ref) }) s.T().Run("contains all the info", func(t *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerAtt.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) // parent i.e attestation s.Equal(wantReferrerAtt.Digest, got.Digest) @@ -206,14 +207,13 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("can get sha1 digests too", func(t *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerCommit.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerCommit.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Equal(wantReferrerCommit.Digest, got.Digest) }) - s.T().Run("can't be accessed by a second user in another org", func(t *testing.T) { - // the user2 has not access to org1 - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user2.ID, nil) + s.T().Run("is not found in another org", func(_ *testing.T) { + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerAtt.Digest, "", []uuid.UUID{s.org2UUID}, nil, nil) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -221,7 +221,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("but another workflow can be attached", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) s.Contains(got.OrgIDs, s.org1UUID) @@ -230,23 +230,23 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { // and it's idempotent (no new orgs added) err = s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, _, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + got, _, err = s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) s.Equal([]uuid.UUID{s.org2UUID, s.org1UUID}, got.OrgIDs) s.Equal([]uuid.UUID{s.workflow2.ID, s.workflow1.ID}, got.WorkflowIDs) }) - s.T().Run("and now user2 has access to it since it has access to workflow2 in org2", func(t *testing.T) { + s.T().Run("and it is found in org2 once workflow2 attached it", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user2.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org2UUID}, nil, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) }) s.T().Run("subject materials are returned connected to the attestation", func(t *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerContainerImage.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) // parent i.e attestation s.Equal(wantReferrerContainerImage.Digest, got.Digest) @@ -260,7 +260,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("non-subject materials also are connected to the attestation", func(t *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerSarif.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) require.Len(t, got.References, 1) s.Equal(wantReferrerAtt.Digest, got.References[0].Digest) @@ -269,7 +269,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("or it does not exist", func(t *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", []uuid.UUID{s.org1UUID}, nil, nil) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -291,20 +291,20 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.NoError(err) // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerSarif.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.Nil(got) s.ErrorContains(err, "present in 2 kinds") }) s.T().Run("it should not fail on retrieval if we filter out by one kind", func(t *testing.T) { // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "SARIF", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerSarif.Digest, "SARIF", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Equal(wantReferrerSarif.Digest, got.Digest) s.Equal(true, got.Downloadable) s.Equal("SARIF", got.Kind) - got, _, err = s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "ARTIFACT", s.user.ID, nil) + got, _, err = s.Referrer.GetFromRoot(ctx, wantReferrerSarif.Digest, "ARTIFACT", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Equal(wantReferrerSarif.Digest, got.Digest) s.Equal(true, got.Downloadable) @@ -313,7 +313,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("now there should a container image pointing to two attestations", func(t *testing.T) { // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerContainerImage.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) // it should be referenced by two attestations since it's subject of both require.Len(t, got.References, 2) @@ -326,7 +326,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("returns the associated workflow IDs", func(_ *testing.T) { - got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, wantReferrerAtt.Digest, "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Equal([]uuid.UUID{s.workflow2.ID, s.workflow1.ID}, got.WorkflowIDs) }) @@ -390,7 +390,7 @@ func (s *referrerIntegrationTestSuite) TestPagination() { require.NoError(t, err) } - got, nextCursor, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, p) + got, nextCursor, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, p) require.NoError(t, err) require.Len(t, got.References, tc.wantCount) @@ -411,7 +411,7 @@ func (s *referrerIntegrationTestSuite) TestPagination() { p, err := pagination.NewCursor(cursor, 1) require.NoError(t, err) - got, nextCursor, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, p) + got, nextCursor, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, p) require.NoError(t, err) require.Len(t, got.References, 1) @@ -460,7 +460,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run.ID, h.String(), false)) s.Run("attestation root is returned when project+version match", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectScope("test", "v1.0.0")) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "v1.0.0")) s.NoError(err) s.Require().NotNil(got) s.Equal(h.String(), got.Digest) @@ -469,19 +469,19 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { }) s.Run("attestation root is not found for a different version", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectScope("test", "v9.9.9")) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "v9.9.9")) s.True(biz.IsNotFound(err)) s.Nil(got) }) s.Run("attestation root is not found for a different project", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectScope("does-not-exist", "v1.0.0")) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("does-not-exist", "v1.0.0")) s.True(biz.IsNotFound(err)) s.Nil(got) }) s.Run("material root is returned when reachable from an in-version attestation", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectScope("test", "v1.0.0")) + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "v1.0.0")) s.NoError(err) s.Require().NotNil(got) s.Equal(sbomDigest, got.Digest) @@ -491,13 +491,13 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { }) s.Run("material root is not found for a different version", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectScope("test", "v9.9.9")) + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "v9.9.9")) s.True(biz.IsNotFound(err)) s.Nil(got) }) s.Run("without the filter the referrer is returned regardless of version", func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) + got, _, err := s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil) s.NoError(err) s.Require().NotNil(got) s.Equal(h.String(), got.Digest) @@ -515,19 +515,19 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runV3.ID, newH, false)) // project_name only (empty version) → SBOM returned via its in-project attestation. - got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectScope("test", "")) + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "")) s.NoError(err) s.Require().NotNil(got) s.Equal(sbomDigest, got.Digest) // Attestation root: returned when its digest belongs to any version of the project. - got, _, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectScope("test", "")) + got, _, err = s.Referrer.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("test", "")) s.NoError(err) s.Require().NotNil(got) s.Equal(h.String(), got.Digest) // Unknown project still NotFound. - got, _, err = s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectScope("does-not-exist", "")) + got, _, err = s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, nil, nil, biz.WithProjectScope("does-not-exist", "")) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -583,7 +583,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { // firstPage gate that allowed a cursor to bypass version scoping). cursor, err := pagination.NewCursor(pagination.EncodeCursor(time.Now(), uuid.New()), 10) require.NoError(s.T(), err) - got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, cursor, biz.WithProjectScope("test", "v2.0.0")) + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, nil, cursor, biz.WithProjectScope("test", "v2.0.0")) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -686,4 +686,90 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVisibility() { _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID, s.org2UUID}, crossOrg, nil) s.Error(err) }) + + s.Run("a referrer shared with a visible project of another organization does not", func() { + // The same attestation is also attached to a project in org2 + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow2.ID.String())) + // The visible list for org1 holds only the org2 project, as a flat list of the + // memberships of a user in several organizations does. The org1 project stays hidden. + polluted := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow2.ProjectID}} + _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID}, polluted, nil) + s.Error(err) + + // In org2, the same project grants visibility + granted := map[biz.OrgID][]biz.ProjectID{s.org2UUID: {s.workflow2.ProjectID}} + got, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org2UUID}, granted, nil) + s.Require().NoError(err) + s.Equal(digest, got.Digest) + }) +} + +// A user discovers only in the given organization, and only with the project visibility +// that the user has in that organization: a visible project in another organization must +// not make a referrer of the given organization visible. +func (s *referrerIntegrationTestSuite) TestGetFromRootUserSingleOrg() { + ctx := context.Background() + envelope, envBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") + attDigest, _, err := v1.SHA256(bytes.NewReader(envBytes)) + require.NoError(s.T(), err) + // The same attestation lives in a project of org1 and in a project of org2 + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow1.ID.String())) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow2.ID.String())) + digest := attDigest.String() + + // A member with RBAC in both organizations who sees only the org2 project + rbacUser, err := s.User.UpsertByEmail(ctx, "rbac-user@test.com", nil) + require.NoError(s.T(), err) + _, err = s.Membership.Create(ctx, s.org1.ID, rbacUser.ID, biz.WithMembershipRole(authz.RoleOrgMember)) + require.NoError(s.T(), err) + _, err = s.Membership.Create(ctx, s.org2.ID, rbacUser.ID, biz.WithMembershipRole(authz.RoleOrgMember), biz.WithCurrentMembership()) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Membership.SetProjectOwner(ctx, s.org2UUID, s.workflow2.ProjectID, uuid.MustParse(rbacUser.ID))) + + testCases := []struct { + name string + userID string + orgID uuid.UUID + wantFound bool + }{ + { + name: "a member of the organization", + userID: s.user.ID, + orgID: s.org1UUID, + wantFound: true, + }, + { + // The org middleware rejects a non-member before discovery, as for any other + // operation. Only an instance admin gets here, and sees the whole organization. + name: "an organization without a membership of the user", + userID: s.user2.ID, + orgID: s.org1UUID, + wantFound: true, + }, + { + name: "a visible project in the organization", + userID: rbacUser.ID, + orgID: s.org2UUID, + wantFound: true, + }, + { + name: "a visible project in another organization does not make it visible", + userID: rbacUser.ID, + orgID: s.org1UUID, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + got, _, err := s.Referrer.GetFromRootUser(ctx, digest, "", tc.userID, tc.orgID, nil) + if !tc.wantFound { + s.True(biz.IsNotFound(err), "expected not found, got %v", err) + s.Nil(got) + return + } + + s.Require().NoError(err) + s.Equal(digest, got.Digest) + }) + } } diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 5866bde9c..56e277ef8 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -384,10 +384,10 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg } // add additional information related to the workflows - hydrateWorkflowsInfo(root, res) + projectsByOrg := hydrateWorkflowsInfo(root, res) // check that, if RBAC is required, the user has visibility on the artifact in at least 1 org/project - if visible := isReferrerVisible(res, allowedOrgs, visibleProjectsMap); !visible { + if visible := isReferrerVisible(projectsByOrg, allowedOrgs, visibleProjectsMap); !visible { return nil, "", biz.NewErrUnauthorizedStr("referrer not allowed") } @@ -505,17 +505,22 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg return res, nextCursor, nil } -func isReferrerVisible(ref *biz.StoredReferrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) bool { - for _, oid := range ref.OrgIDs { +// isReferrerVisible reports whether the referrer, attached to the given projects of each organization, +// is visible in at least one allowed organization. Project visibility is evaluated per organization, +// like projectVisibilityPredicate: only the referrer's projects of an organization are compared with +// the visible projects of that same organization, so a project of another organization never makes +// the referrer visible, even if the visible list holds it. +func isReferrerVisible(projectsByOrg map[uuid.UUID][]uuid.UUID, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) bool { + for oid, referrerProjects := range projectsByOrg { if !slices.Contains(allowedOrgs, oid) { // skip check in organizations where the user doesn't have access continue } if visibleProjects, ok := visibleProjectsMap[oid]; ok { // if entry is present, it means we need to apply RBAC - // check if visible projects and referrer projects match + // check if visible projects and the referrer projects of this organization match // by checking if any project is visible by the user - for _, pid := range ref.ProjectIDs { + for _, pid := range referrerProjects { if slices.Contains(visibleProjects, pid) { return true } @@ -532,23 +537,34 @@ func isReferrerVisible(ref *biz.StoredReferrer, allowedOrgs []uuid.UUID, visible // hydrate the referrer with the following information: // - workflowIDs: the list of associated workflows // - orgIDs: the list of associated organizations -func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) { +// - projectIDs: the list of associated projects +// It also returns the associated projects grouped by organization, for the visibility check. +func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) map[uuid.UUID][]uuid.UUID { workflowIDs := make([]uuid.UUID, 0, len(root.Edges.Workflows)) projectIDs := make(map[uuid.UUID]bool, 0) orgIDs := make([]uuid.UUID, 0) - orgsMap := make(map[uuid.UUID]struct{}, 0) + // a set per organization keeps the deduplication linear in the number of workflows + projectSetsByOrg := make(map[uuid.UUID]map[uuid.UUID]struct{}, 0) for _, wf := range root.Edges.Workflows { workflowIDs = append(workflowIDs, wf.ID) - if _, ok := orgsMap[wf.OrganizationID]; !ok { + projectSet, ok := projectSetsByOrg[wf.OrganizationID] + if !ok { orgIDs = append(orgIDs, wf.OrganizationID) + projectSet = make(map[uuid.UUID]struct{}) + projectSetsByOrg[wf.OrganizationID] = projectSet } - if _, ok := projectIDs[wf.ProjectID]; !ok { - projectIDs[wf.ProjectID] = true - } - orgsMap[wf.OrganizationID] = struct{}{} + projectSet[wf.ProjectID] = struct{}{} + projectIDs[wf.ProjectID] = true + } + + projectsByOrg := make(map[uuid.UUID][]uuid.UUID, len(projectSetsByOrg)) + for orgID, projectSet := range projectSetsByOrg { + projectsByOrg[orgID] = maps.Keys(projectSet) } out.ProjectIDs = maps.Keys(projectIDs) out.WorkflowIDs = workflowIDs out.OrgIDs = orgIDs + + return projectsByOrg } diff --git a/app/controlplane/pkg/data/referrer_test.go b/app/controlplane/pkg/data/referrer_test.go index e23a11f45..d9ba098e9 100644 --- a/app/controlplane/pkg/data/referrer_test.go +++ b/app/controlplane/pkg/data/referrer_test.go @@ -22,6 +22,8 @@ import ( "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/referrer" "github.com/google/uuid" @@ -226,3 +228,124 @@ func TestReferrerVisibleToOrgs(t *testing.T) { assert.Contains(t, args, any(o), "every allowed organization is bound into the filter") } } + +// TestIsReferrerVisible checks that project visibility is evaluated per organization: a project +// that is visible in one organization never makes a referrer visible in another organization. +func TestIsReferrerVisible(t *testing.T) { + orgCurrent, orgOther := uuid.New(), uuid.New() + projHidden, projVisible, projOtherOrg := uuid.New(), uuid.New(), uuid.New() + + testCases := []struct { + name string + projectsByOrg map[uuid.UUID][]uuid.UUID + allowedOrgs []uuid.UUID + visibleByOrg map[uuid.UUID][]uuid.UUID + expectedResult bool + }{ + { + name: "no RBAC in the allowed organization", + projectsByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projHidden}}, + allowedOrgs: []uuid.UUID{orgCurrent}, + expectedResult: true, + }, + { + name: "visible project in the allowed organization", + projectsByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projHidden, projVisible}}, + allowedOrgs: []uuid.UUID{orgCurrent}, + visibleByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projVisible}}, + expectedResult: true, + }, + { + name: "no visible project in the allowed organization", + projectsByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projHidden}}, + allowedOrgs: []uuid.UUID{orgCurrent}, + visibleByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projVisible}}, + expectedResult: false, + }, + { + // The referrer is in a hidden project of the current organization and in a visible + // project of another organization. The visible list of the current organization also + // holds the project of the other organization, as a flat list of memberships does. + name: "shared referrer: a project of another organization does not grant visibility", + projectsByOrg: map[uuid.UUID][]uuid.UUID{ + orgCurrent: {projHidden}, + orgOther: {projOtherOrg}, + }, + allowedOrgs: []uuid.UUID{orgCurrent}, + visibleByOrg: map[uuid.UUID][]uuid.UUID{orgCurrent: {projVisible, projOtherOrg}}, + expectedResult: false, + }, + { + name: "referrer only in an organization that is not allowed", + projectsByOrg: map[uuid.UUID][]uuid.UUID{orgOther: {projOtherOrg}}, + allowedOrgs: []uuid.UUID{orgCurrent}, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expectedResult, isReferrerVisible(tc.projectsByOrg, tc.allowedOrgs, tc.visibleByOrg)) + }) + } +} + +// referrerWithWorkflows returns a referrer attached to one workflow per project, plus a second +// workflow on each project, spread over the given organizations. +func referrerWithWorkflows(orgs []uuid.UUID, projectsPerOrg int) (*ent.Referrer, map[uuid.UUID][]uuid.UUID) { + root := &ent.Referrer{} + want := make(map[uuid.UUID][]uuid.UUID, len(orgs)) + for _, orgID := range orgs { + for range projectsPerOrg { + projectID := uuid.New() + want[orgID] = append(want[orgID], projectID) + // two workflows of the same project: the project must be listed once + for range 2 { + root.Edges.Workflows = append(root.Edges.Workflows, &ent.Workflow{ID: uuid.New(), OrganizationID: orgID, ProjectID: projectID}) + } + } + } + + return root, want +} + +func TestHydrateWorkflowsInfo(t *testing.T) { + testCases := []struct { + name string + orgs int + projectsPerOrg int + }{ + {name: "no workflows"}, + {name: "one project in one organization", orgs: 1, projectsPerOrg: 1}, + {name: "projects in several organizations", orgs: 3, projectsPerOrg: 4}, + {name: "many projects on one referrer", orgs: 2, projectsPerOrg: 2000}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + orgs := make([]uuid.UUID, 0, tc.orgs) + for range tc.orgs { + orgs = append(orgs, uuid.New()) + } + root, want := referrerWithWorkflows(orgs, tc.projectsPerOrg) + + out := &biz.StoredReferrer{} + got := hydrateWorkflowsInfo(root, out) + + require.Len(t, got, len(want)) + for orgID, projects := range want { + assert.ElementsMatch(t, projects, got[orgID], "the projects of each organization are listed once, in their organization") + } + assert.Equal(t, orgs, out.OrgIDs) + assert.Len(t, out.ProjectIDs, tc.orgs*tc.projectsPerOrg) + assert.Len(t, out.WorkflowIDs, 2*tc.orgs*tc.projectsPerOrg) + }) + } +} + +func BenchmarkHydrateWorkflowsInfo(b *testing.B) { + root, _ := referrerWithWorkflows([]uuid.UUID{uuid.New()}, 5000) + for b.Loop() { + hydrateWorkflowsInfo(root, &biz.StoredReferrer{}) + } +} diff --git a/docs/specs/issue-3776-single-org-discovery.md b/docs/specs/issue-3776-single-org-discovery.md new file mode 100644 index 000000000..da2b00c24 --- /dev/null +++ b/docs/specs/issue-3776-single-org-discovery.md @@ -0,0 +1,121 @@ +--- +owner: migmartri +ticket: https://github.com/chainloop-dev/chainloop/issues/3776 +prd: +--- + + + +# Spec issue-3776: Single-organization referrer discovery + +## Summary + +Referrer discovery finds an artifact or a piece of evidence by its digest. It also returns the items that link to it in the Chainloop graph. Today, discovery with a user session searches every organization that the user is a member of. All other control plane operations, and discovery with an API token, work in one organization only. This spec makes discovery work only in the current organization, for all credentials. The change is in the control plane only. It applies to all clients: the discover API, the `chainloop discover` command, the policy functions, and the web UI. + +## Problem + +- Discovery with a user session is the only operation that crosses organization boundaries. A user expects the results of the selected organization, as in all other operations. +- The result of a policy that calls discovery depends on the credential that evaluates it. With an API token, the policy sees one organization. With a user session, it sees all organizations of that user. The same policy can pass in one case and fail in the other. +- A user cannot limit discovery to one organization. The `--org` option of the CLI selects an organization, but discovery ignores it. + +## Goals and Non-Goals + +- Goal: discovery returns only the data of the current organization, for every credential and every client. +- Goal: a policy that calls discovery gets the same result with a user session and with an API token. +- Non-goal: a way to search more than one organization. No client needs it, and it would bring back the problem. See D-001. +- Non-goal: change a client. The new behavior comes from the control plane only. See D-003. +- Non-goal: change project visibility in an organization, or the pagination limits of discovery. They stay as they are. +- Non-goal: change public discovery of shared referrers. It does not use the organizations of the user. + +## Requirements + +### R-001: Only the current organization +With a user session, discovery MUST return only the referrers of the current organization. It MUST return only their references in that organization. A digest that is in other organizations of the user, but not in the current one, MUST give a not-found error. +- Done when: a user is a member of organizations A and B, and only a run in A attested a digest. With A as the current organization, discovery finds it. With B as the current organization, discovery returns not found. + +### R-004: Policy function behavior +The `chainloop.discover` policy function and the discover function of WebAssembly policies MUST return only the data of the current organization. Their inputs and their output format MUST not change. + +### R-005: Same result for users and API tokens +For the same organization, digest, and project visibility, discovery MUST return the same result with a user session and with an API token. A visible project of the user in another organization MUST NOT make a referrer of the current organization visible. + +## Constraints + +- Compatibility with clients: the request and the response formats do not change. +- An old client against a new control plane gets results from the current organization only. This is the intended behavior. It is not an error. +- Project visibility in the organization and the pagination limits stay in place on every path. +- Public repository: no internal links, and no customer or user information. + +## Proposal + +The user selects the organization as for all other operations: with the `--org` option or its environment variable, or with the default organization of the user. Discovery then works in that organization only. A user who expects a result from another organization selects that organization and runs the command again. The not-found message is the same as today. + +The control plane finds the current organization of the request as for every other operation. For a user session, the organization comes from the organization header of the request or from the default membership of the user. An API token uses its own organization. An instance-scoped API token uses the organization header. The control plane makes sure that the caller can work in that organization, with the same check as all other operations. It then searches the graph in that organization only. It applies the project visibility that the caller has in that organization, and no visibility from other organizations. + +The policy functions and the web UI use the same path. Policies and clients do not change. + +```mermaid +sequenceDiagram + participant C as Client (CLI, policy engine, web UI) + participant CP as Control plane + C->>CP: Discover digest (organization header or default membership) + CP->>CP: Find the current organization and check the access, as for all operations + CP->>CP: Search the graph in that organization, with the project visibility in it + CP-->>C: Result of the current organization only +``` + +## Decision Record + +### D-001: Discovery is always single-organization +No option searches more than one organization. This replaces the first design of the implementation PR, which added an opt-in limit to the current organization. +- Why: no other operation crosses organization boundaries, and an option to cross them brings back the difference between users and API tokens. +- Alternatives considered: + - Single organization by default, with an opt-in to search all organizations: it keeps an exception in the API that no client needs. + - Search all organizations by default, with an opt-in limit to the current one: the problem stays for each caller without the option. +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4467148631) + +### D-002: The current organization is the one of the request +Discovery works as every other API operation. For a user session, the organization comes from the organization header of the request, or from the default membership of the user. An API token uses its own organization, and an instance-scoped API token uses the organization header. The control plane makes sure that it is an organization of the caller, with the check of all other operations. Discovery does not take an organization of its own, and it does not add a check of its own. +- Why: one rule for all operations. The CLI `--org` option then works for discovery with no special case. +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4468337274) + +### D-003: No client change +The change is new behavior of the control plane only. The response does not report the scope, and the CLI and the policy engine do not check it. An older control plane keeps its behavior until the operator upgrades it. This drops R-002 (the control plane reports the searched organization) and R-003 (the CLI and the policy engine fail closed). It also drops D-004 (only user-session clients check the report). +- Why: the behavior belongs to the control plane, as for every other operation. A client check adds a new failure against older control planes for a behavior that the upgrade fixes. +- Alternatives considered: + - The control plane reports the scope, and a client with a user session fails closed without the report: it adds a client change. It also adds a new error for a temporary state. +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4468342762) + +### D-005: One policy function +`chainloop.discover` keeps its name and becomes single-organization through the control plane. The implementation PR first added a separate current-organization function. The PR deletes it before release. +- Why: one function with one behavior. Two functions with the same result would confuse policy authors. +- Alternatives considered: + - Keep the second function as an alias: it adds a name that does nothing different. +- Source: drafting + +## Open Questions + +No open questions. + +## Risks + +| Risk | Mitigation | +|------|------------| +| A user or a script relies on discovery across organizations with a user session, and gets not found after the upgrade. | Release note. The user selects the other organization with `--org`. | +| An older control plane keeps searching all organizations of a user until the operator upgrades it. | Release note. The upgrade of the control plane gives the new behavior to all clients. | +| The web UI graph no longer shows items that are only in other organizations of the user. | This is the intended behavior. The UI already works in one organization in all other views. |