From efb080f244bf2ad973f663599ae362d530e9ab7f Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 11:00:22 +0100 Subject: [PATCH 1/8] feat(discover): allow scoping discovery to a single organization Add a current_org_only field to ReferrerService.DiscoverPrivate. When it is set, a user's discovery covers only the current organization instead of every organization the user is a member of. The default behavior does not change, and API tokens are already scoped to their own organization. The CLI sets the field when the organization is selected explicitly with --org or its environment variable. A new chainloop.discover_current_org Rego builtin exposes the same scoping to policies. Closes #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- app/cli/cmd/referrer_discover.go | 10 +- app/cli/documentation/cli-reference.md | 8 ++ app/cli/pkg/action/referrer_discover.go | 37 ++++- app/cli/pkg/action/referrer_discover_test.go | 63 +++++++++ .../api/controlplane/v1/referrer.pb.go | 16 ++- .../api/controlplane/v1/referrer.proto | 4 + .../gen/frontend/controlplane/v1/referrer.ts | 21 ++- ...viceDiscoverPrivateRequest.jsonschema.json | 8 ++ ...rServiceDiscoverPrivateRequest.schema.json | 8 ++ app/controlplane/api/gen/openapi/openapi.yaml | 13 ++ app/controlplane/internal/service/referrer.go | 9 ++ app/controlplane/pkg/biz/referrer.go | 25 ++++ .../pkg/biz/referrer_integration_test.go | 58 ++++++++ pkg/policies/engine/builtins/rego_test.go | 132 ++++++++++++++++++ 14 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 app/cli/pkg/action/referrer_discover_test.go create mode 100644 pkg/policies/engine/builtins/rego_test.go diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index fbedf4378..e6f39ce61 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -31,13 +31,21 @@ func newReferrerDiscoverCmd() *cobra.Command { cmd := &cobra.Command{ Use: "discover", Short: "(Preview) inspect pieces of evidence or artifacts stored through Chainloop", + Long: `(Preview) inspect pieces of evidence or artifacts stored through Chainloop. + +When logged in as a user, the discovery covers every organization you are a member of. +Set the organization explicitly with --org (or its environment variable) to search only that organization. +An API token always searches only its own organization.`, RunE: func(cmd *cobra.Command, args []string) error { pagination := &action.PaginationOpts{ Limit: paginationOpts.Limit, NextCursor: paginationOpts.NextCursor, } - res, err := action.NewReferrerDiscoverPrivate(ActionOpts).Run(context.Background(), digest, kind, pagination) + res, err := action.NewReferrerDiscoverPrivate(ActionOpts, + // An explicitly selected organization scopes the discovery to it + action.WithCurrentOrgOnly(organizationExplicitlySelected(cmd)), + ).Run(context.Background(), digest, kind, pagination) if err != nil { return err } diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index 875069029..e8b45fb10 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -1404,6 +1404,14 @@ 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. + +When logged in as a user, the discovery covers every organization you are a member of. +Set the organization explicitly with --org (or its environment variable) to search only that organization. +An API token always searches only its own organization. + ``` chainloop discover [flags] ``` diff --git a/app/cli/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 44c903be1..0089dba48 100644 --- a/app/cli/pkg/action/referrer_discover.go +++ b/app/cli/pkg/action/referrer_discover.go @@ -24,6 +24,19 @@ import ( type ReferrerDiscover struct { cfg *ActionsOpts + // currentOrgOnly scopes the discovery to the current organization instead of + // every organization the user is a member of. + currentOrgOnly bool +} + +// ReferrerDiscoverOpt configures a ReferrerDiscover action. +type ReferrerDiscoverOpt func(*ReferrerDiscover) + +// WithCurrentOrgOnly scopes the discovery to the current organization. +func WithCurrentOrgOnly(currentOrgOnly bool) ReferrerDiscoverOpt { + return func(r *ReferrerDiscover) { + r.currentOrgOnly = currentOrgOnly + } } type ReferrerItem struct { @@ -41,17 +54,18 @@ type ReferrerDiscoverResult struct { NextCursor string `json:"nextCursor,omitempty"` } -func NewReferrerDiscoverPrivate(cfg *ActionsOpts) *ReferrerDiscover { - return &ReferrerDiscover{cfg} +func NewReferrerDiscoverPrivate(cfg *ActionsOpts, opts ...ReferrerDiscoverOpt) *ReferrerDiscover { + r := &ReferrerDiscover{cfg: cfg} + for _, opt := range opts { + opt(r) + } + + return r } func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string, p *PaginationOpts) (*ReferrerDiscoverResult, error) { client := pb.NewReferrerServiceClient(action.cfg.CPConnection) - resp, err := client.DiscoverPrivate(ctx, &pb.ReferrerServiceDiscoverPrivateRequest{ - Digest: digest, - Kind: kind, - Pagination: paginationOptsToPb(p), - }) + resp, err := client.DiscoverPrivate(ctx, action.newRequest(digest, kind, p)) if err != nil { return nil, err } @@ -59,6 +73,15 @@ func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string, p return newReferrerDiscoverResult(resp.Result, resp.GetPagination()), nil } +func (action *ReferrerDiscover) newRequest(digest, kind string, p *PaginationOpts) *pb.ReferrerServiceDiscoverPrivateRequest { + return &pb.ReferrerServiceDiscoverPrivateRequest{ + Digest: digest, + Kind: kind, + Pagination: paginationOptsToPb(p), + CurrentOrgOnly: action.currentOrgOnly, + } +} + func paginationOptsToPb(p *PaginationOpts) *pb.CursorPaginationRequest { if p == nil { return nil diff --git a/app/cli/pkg/action/referrer_discover_test.go b/app/cli/pkg/action/referrer_discover_test.go new file mode 100644 index 000000000..485f9a3e5 --- /dev/null +++ b/app/cli/pkg/action/referrer_discover_test.go @@ -0,0 +1,63 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReferrerDiscoverNewRequest(t *testing.T) { + testCases := []struct { + name string + opts []ReferrerDiscoverOpt + pagination *PaginationOpts + wantCurrentOrgOnly bool + wantLimit int32 + }{ + { + name: "defaults to a cross-organization discovery", + }, + { + name: "scoped to the current organization", + opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(true)}, + wantCurrentOrgOnly: true, + }, + { + name: "explicitly not scoped", + opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(false)}, + }, + { + name: "keeps the pagination", + opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(true)}, + pagination: &PaginationOpts{Limit: 5, NextCursor: "cursor"}, + wantCurrentOrgOnly: true, + wantLimit: 5, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := NewReferrerDiscoverPrivate(&ActionsOpts{}, tc.opts...).newRequest("sha256:deadbeef", "CONTAINER_IMAGE", tc.pagination) + + assert.Equal(t, "sha256:deadbeef", req.GetDigest()) + assert.Equal(t, "CONTAINER_IMAGE", req.GetKind()) + assert.Equal(t, tc.wantCurrentOrgOnly, req.GetCurrentOrgOnly()) + assert.Equal(t, tc.wantLimit, req.GetPagination().GetLimit()) + }) + } +} diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 160ea580b..b068d3823 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -56,6 +56,10 @@ type ReferrerServiceDiscoverPrivateRequest struct { // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). // Requires project_name, since a version name is unique only within a project. ProjectVersion string `protobuf:"bytes,5,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` + // CurrentOrgOnly scopes the discovery to the current organization. By default, a user + // searches every organization they are a member of. An API token always searches only + // its own organization, so the field does not change the result for API tokens. + CurrentOrgOnly bool `protobuf:"varint,6,opt,name=current_org_only,json=currentOrgOnly,proto3" json:"current_org_only,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -125,6 +129,13 @@ func (x *ReferrerServiceDiscoverPrivateRequest) GetProjectVersion() string { return "" } +func (x *ReferrerServiceDiscoverPrivateRequest) GetCurrentOrgOnly() bool { + if x != nil { + return x.CurrentOrgOnly + } + return false +} + // ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method type ReferrerServiceDiscoverPrivateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -284,7 +295,7 @@ var File_controlplane_v1_referrer_proto protoreflect.FileDescriptor const file_controlplane_v1_referrer_proto_rawDesc = "" + "\n" + - "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xf0\x03\n" + + "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\x9a\x04\n" + "%ReferrerServiceDiscoverPrivateRequest\x12\x1f\n" + "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12H\n" + @@ -292,7 +303,8 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "pagination\x18\x03 \x01(\v2(.controlplane.v1.CursorPaginationRequestR\n" + "pagination\x12!\n" + "\fproject_name\x18\x04 \x01(\tR\vprojectName\x12'\n" + - "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion:\xfb\x01\x92AQ\n" + + "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion\x12(\n" + + "\x10current_org_only\x18\x06 \x01(\bR\x0ecurrentOrgOnly:\xfb\x01\x92AQ\n" + "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\xbaH\xa3\x01\x1a\xa0\x01\n" + ".discover_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\x82\x02\n" + "&ReferrerServiceDiscoverPrivateResponse\x125\n" + diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index ce5be09f9..c7fe130de 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -56,6 +56,10 @@ message ReferrerServiceDiscoverPrivateRequest { // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). // Requires project_name, since a version name is unique only within a project. string project_version = 5; + // CurrentOrgOnly scopes the discovery to the current organization. By default, a user + // searches every organization they are a member of. An API token always searches only + // its own organization, so the field does not change the result for API tokens. + bool current_org_only = 6; // project_version requires project_name (a version name is unique only within a project); // project_name on its own is allowed. diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 270c7e357..99127631c 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -28,6 +28,12 @@ export interface ReferrerServiceDiscoverPrivateRequest { * Requires project_name, since a version name is unique only within a project. */ projectVersion: string; + /** + * CurrentOrgOnly scopes the discovery to the current organization. By default, a user + * searches every organization they are a member of. An API token always searches only + * its own organization, so the field does not change the result for API tokens. + */ + currentOrgOnly: boolean; } /** ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method */ @@ -67,7 +73,7 @@ export interface ReferrerItem_AnnotationsEntry { } function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { - return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "" }; + return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "", currentOrgOnly: false }; } export const ReferrerServiceDiscoverPrivateRequest = { @@ -87,6 +93,9 @@ export const ReferrerServiceDiscoverPrivateRequest = { if (message.projectVersion !== "") { writer.uint32(42).string(message.projectVersion); } + if (message.currentOrgOnly === true) { + writer.uint32(48).bool(message.currentOrgOnly); + } return writer; }, @@ -132,6 +141,13 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.projectVersion = reader.string(); continue; + case 6: + if (tag !== 48) { + break; + } + + message.currentOrgOnly = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -148,6 +164,7 @@ export const ReferrerServiceDiscoverPrivateRequest = { pagination: isSet(object.pagination) ? CursorPaginationRequest.fromJSON(object.pagination) : undefined, projectName: isSet(object.projectName) ? String(object.projectName) : "", projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", + currentOrgOnly: isSet(object.currentOrgOnly) ? Boolean(object.currentOrgOnly) : false, }; }, @@ -159,6 +176,7 @@ export const ReferrerServiceDiscoverPrivateRequest = { (obj.pagination = message.pagination ? CursorPaginationRequest.toJSON(message.pagination) : undefined); message.projectName !== undefined && (obj.projectName = message.projectName); message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); + message.currentOrgOnly !== undefined && (obj.currentOrgOnly = message.currentOrgOnly); return obj; }, @@ -179,6 +197,7 @@ export const ReferrerServiceDiscoverPrivateRequest = { : undefined; message.projectName = object.projectName ?? ""; message.projectVersion = object.projectVersion ?? ""; + message.currentOrgOnly = object.currentOrgOnly ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json index 6e59f03fc..13a2be3fe 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", "patternProperties": { + "^(current_org_only)$": { + "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", + "type": "boolean" + }, "^(project_name)$": { "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", "type": "string" @@ -14,6 +18,10 @@ } }, "properties": { + "currentOrgOnly": { + "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", + "type": "boolean" + }, "digest": { "description": "Digest is the unique identifier of the referrer to discover", "minLength": 1, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json index e7901c82f..16a4e5fdd 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", "patternProperties": { + "^(currentOrgOnly)$": { + "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", + "type": "boolean" + }, "^(projectName)$": { "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", "type": "string" @@ -14,6 +18,10 @@ } }, "properties": { + "current_org_only": { + "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", + "type": "boolean" + }, "digest": { "description": "Digest is the unique identifier of the referrer to discover", "minLength": 1, diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index f0c9ab662..d8117c98a 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -74,6 +74,19 @@ paths: name: project_version schema: type: string + - description: >- + CurrentOrgOnly scopes the discovery to the current organization. By + default, a user + + searches every organization they are a member of. An API token + always searches only + + its own organization, so the field does not change the result for + API tokens. + in: query + name: current_org_only + schema: + type: boolean responses: '200': content: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 107bca445..35c5224fa 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -69,6 +69,15 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS var referrer *biz.StoredReferrer var nextCursor string if currentUser != nil { + // Optionally narrow the user's cross-organization discovery to the current organization. + if req.GetCurrentOrgOnly() { + orgUUID, parseErr := uuid.Parse(currentOrg.ID) + if parseErr != nil { + return nil, fmt.Errorf("invalid org UUID: %w", parseErr) + } + extraFilters = append(extraFilters, biz.WithOrgScope(orgUUID)) + } + referrer, nextCursor, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID, paginationOpts, extraFilters...) } else if currentToken != nil { var orgUUID uuid.UUID diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 06bbfb019..a96d94ce4 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "slices" "sort" "time" @@ -104,6 +105,9 @@ type GetFromRootFilters struct { // Both must be set together (a version name is unique only within a project). ProjectName *string ProjectVersion *string + // OrgID narrows the discovery to a single organization. It only restricts the + // organizations allowed by the caller; it never grants access to another one. + OrgID *uuid.UUID } type GetFromRootFilter func(*GetFromRootFilters) @@ -123,6 +127,14 @@ func WithProjectScope(projectName, projectVersion string) func(*GetFromRootFilte } } +// WithOrgScope narrows the discovery to the given organization. A referrer is found +// only if the organization is also one of the organizations allowed for the caller. +func WithOrgScope(orgID uuid.UUID) func(*GetFromRootFilters) { + return func(o *GetFromRootFilters) { + o.OrgID = &orgID + } +} + // WithVisibleProjectIDs sets visible projects by org for organizations with RBAC enabled for the user (role is OrgMember) func WithVisibleProjectIDs(projectIDs map[OrgID][]ProjectID) func(*GetFromRootFilters) { return func(o *GetFromRootFilters) { @@ -196,6 +208,19 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri } filters = append(filters, extraFilters...) + opts := &GetFromRootFilters{} + for _, f := range filters { + f(opts) + } + + // An organization scope intersects with the allowed organizations, it never widens them. + if opts.OrgID != nil { + if !slices.Contains(orgIDs, *opts.OrgID) { + return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) + } + orgIDs = []uuid.UUID{*opts.OrgID} + } + ref, nextCursor, err := s.repo.GetFromRoot(ctx, digest, orgIDs, p, filters...) if err != nil { if errors.As(err, &ErrAmbiguousReferrer{}) { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 355413601..cd507936d 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -687,3 +687,61 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVisibility() { s.Error(err) }) } + +// An organization scope narrows a user's cross-organization discovery to a single +// organization, and never widens it to one the user is not a member of. +func (s *referrerIntegrationTestSuite) TestGetFromRootUserOrgScope() { + 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 attestation lives in org1 only + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow1.ID.String())) + digest := attDigest.String() + + testCases := []struct { + name string + userID string + filters []biz.GetFromRootFilter + wantFound bool + }{ + { + name: "no scope searches every organization of the user", + userID: s.user.ID, + wantFound: true, + }, + { + name: "scoped to the organization that holds the referrer", + userID: s.user.ID, + filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org1UUID)}, + wantFound: true, + }, + { + name: "scoped to another organization of the user", + userID: s.user.ID, + filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org2UUID)}, + wantFound: false, + }, + { + name: "scoped to an organization the user is not a member of", + userID: s.user2.ID, + filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org1UUID)}, + wantFound: false, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + got, _, err := s.Referrer.GetFromRootUser(ctx, digest, "", tc.userID, nil, tc.filters...) + 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) + s.Equal([]uuid.UUID{s.org1UUID}, got.OrgIDs) + }) + } +} diff --git a/pkg/policies/engine/builtins/rego_test.go b/pkg/policies/engine/builtins/rego_test.go new file mode 100644 index 000000000..735d564c8 --- /dev/null +++ b/pkg/policies/engine/builtins/rego_test.go @@ -0,0 +1,132 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package builtins + +import ( + "context" + "net" + "sync" + "testing" + + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +const containerImageKind = "CONTAINER_IMAGE" + +// fakeReferrerService records the discover requests it receives. +type fakeReferrerService struct { + v1.UnimplementedReferrerServiceServer + mu sync.Mutex + requests []*v1.ReferrerServiceDiscoverPrivateRequest +} + +func (f *fakeReferrerService) DiscoverPrivate(_ context.Context, req *v1.ReferrerServiceDiscoverPrivateRequest) (*v1.ReferrerServiceDiscoverPrivateResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, req) + + return &v1.ReferrerServiceDiscoverPrivateResponse{ + Result: &v1.ReferrerItem{Digest: req.GetDigest(), Kind: containerImageKind}, + }, nil +} + +func (f *fakeReferrerService) lastRequest() *v1.ReferrerServiceDiscoverPrivateRequest { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.requests) == 0 { + return nil + } + + return f.requests[len(f.requests)-1] +} + +func newFakeReferrerConn(t *testing.T) (*grpc.ClientConn, *fakeReferrerService) { + t.Helper() + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + fake := &fakeReferrerService{} + v1.RegisterReferrerServiceServer(srv, fake) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn, fake +} + +func TestDiscoverBuiltins(t *testing.T) { + conn, fake := newFakeReferrerConn(t) + require.NoError(t, RegisterDiscoverBuiltin(conn)) + + testCases := []struct { + name string + query string + wantKind string + wantCurrentOrgOnly bool + }{ + { + name: "discover searches every organization", + query: `chainloop.discover("sha256:deadbeef", "")`, + }, + { + name: "discover forwards the kind", + query: `chainloop.discover("sha256:deadbeef", "CONTAINER_IMAGE")`, + wantKind: containerImageKind, + }, + { + name: "discover_current_org scopes to the current organization", + query: `chainloop.discover_current_org("sha256:deadbeef", "")`, + wantCurrentOrgOnly: true, + }, + { + name: "discover_current_org forwards the kind", + query: `chainloop.discover_current_org("sha256:deadbeef", "CONTAINER_IMAGE")`, + wantKind: containerImageKind, + wantCurrentOrgOnly: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rs, err := rego.New(rego.Query(tc.query)).Eval(context.Background()) + require.NoError(t, err) + require.Len(t, rs, 1) + require.Len(t, rs[0].Expressions, 1) + + got, ok := rs[0].Expressions[0].Value.(map[string]any) + require.True(t, ok, "unexpected result type %T", rs[0].Expressions[0].Value) + assert.Equal(t, "sha256:deadbeef", got["digest"]) + + req := fake.lastRequest() + require.NotNil(t, req) + assert.Equal(t, "sha256:deadbeef", req.GetDigest()) + assert.Equal(t, tc.wantKind, req.GetKind()) + assert.Equal(t, tc.wantCurrentOrgOnly, req.GetCurrentOrgOnly()) + }) + } +} From 8db3c8012963fb8d3c425752c48a0107f510bcdc Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 14:14:35 +0100 Subject: [PATCH 2/8] spec: issue-3776 Single-organization referrer discovery Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- docs/specs/issue-3776-single-org-discovery.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/specs/issue-3776-single-org-discovery.md 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..6214ca063 --- /dev/null +++ b/docs/specs/issue-3776-single-org-discovery.md @@ -0,0 +1,148 @@ +--- +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. This applies to all clients: the discover API, the `chainloop discover` command, the policy functions, and the web UI. The control plane reports the organization that it searched. The CLI and the policy engine, with a user session, reject a response without that report. Thus an older control plane cannot give a client results from other organizations without an error. + +## 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. +- Goal: a new client never gets results from other organizations from an older control plane without an error. +- 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 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-002: The control plane reports the searched organization +Each successful discovery response MUST tell the client that the control plane limited the search to the current organization. + +### R-003: The CLI and the policy engine fail closed +With a user session, the CLI and the policy engine MUST return an error for a discovery response without the report of R-002. The error MUST tell the user to upgrade the control plane. With an API token, they MUST NOT return an error for this reason. +- Done when: a control plane does not report the scope. With a user session, the CLI and the policy engine return the upgrade error. With an API token against the same control plane, they return the result. + +### 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. + +## Constraints + +- Compatibility with clients: the request format does not change. A response gets one new item of data, and old clients ignore it. +- 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. For a user session, it makes sure that the user is a member of that organization, as for all other operations. It searches the graph in that organization only, with the project visibility of the caller. Then it adds a report to the response: the search covered only the current organization. + +With a user session, the CLI and the policy engine check for that report in each response. When it is missing, the control plane is older than this change and searched all organizations of the user. They discard the result and return an error that asks for an upgrade. With an API token they do not check, because an older control plane already limited that search to one organization. + +The policy functions use the same path. Policies do not change. The web UI ships with its control plane, so it does not need the check. It shows the graph of the current organization only. + +```mermaid +sequenceDiagram + participant C as Client (CLI, policy engine, web UI) + participant CP as Control plane + C->>CP: Discover digest (current organization selected) + CP->>CP: Find the current organization and check the membership + CP->>CP: Search the graph in that organization, with project visibility + CP-->>C: Result, with "limited to current organization" + alt User session and the report is missing (older control plane) + C-->>C: Discard the result, return "upgrade the control plane" + else Report present, or API token + C-->>C: Return the result + end +``` + +## 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 uses the organization that the control plane selects for every other operation of the request. It does not take an organization of its own. +- Why: one rule for all operations. The CLI `--org` option then works for discovery with no special case. +- Source: drafting + +### D-003: The control plane reports the scope, and the client fails closed +The response says that the search covered only the current organization. A client that expects the limit and does not get the report returns an error. +- Why: an older control plane ignores data it does not know. It returns results of all organizations without an error. Only a report from the control plane lets the client detect this. +- Alternatives considered: + - Accept the old behavior until the operator upgrades the control plane: a policy can pass or fail for the wrong reason without a sign. + - Filter the results in the client: the response does not carry the organization of every item. The filter would also duplicate the access rules of the control plane. +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4466205569) + +### D-004: Only user-session clients check the report +A client with an API token does not reject a response without the report. +- Why: an older control plane already limits API-token discovery to one organization. A check there would break CI pipelines against older control planes. It would also break policy evaluation there, for no gain. +- Alternatives considered: + - Check every response: simpler, but a new CLI or policy engine stops working with every older control plane. +- Source: drafting + +### 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 + +### D-006: Spec and implementation in the same PR +The spec ships in the implementation PR, and the PR changes to match it. +- Why: the reviewers asked for the spec on the implementation PR, and the change is small. +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4467148631) + +## 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`. | +| A new CLI with a user session against an older control plane cannot use discovery. | The error tells the user to upgrade the control plane. API-token pipelines are not affected (D-004). | +| 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. | From 08f46e4112ed977a9e54ccc8143a0a739bc9e587 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 14:36:03 +0100 Subject: [PATCH 3/8] feat(discover): limit discovery to the current organization Implements spec issue-3776. Referrer discovery now always covers only the current organization, for users and API tokens, through one path with the project visibility of the caller. The opt-in current_org_only field and the chainloop.discover_current_org builtin of the first design are removed. The discover response reports the single-organization scope. With a user session, the CLI connection rejects a discover response that does not report it, so an older control plane cannot return the results of other organizations to the discover command or to the policy builtins. Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- app/cli/cmd/referrer_discover.go | 11 +- app/cli/documentation/cli-reference.md | 4 +- app/cli/pkg/action/referrer_discover.go | 37 +---- app/cli/pkg/action/referrer_discover_test.go | 63 --------- .../api/controlplane/v1/referrer.pb.go | 38 ++--- .../api/controlplane/v1/referrer.proto | 8 +- .../api/controlplane/v1/referrer_scope.go | 45 ++++++ .../controlplane/v1/referrer_scope_test.go | 81 +++++++++++ .../gen/frontend/controlplane/v1/referrer.ts | 42 +++--- ...viceDiscoverPrivateRequest.jsonschema.json | 8 -- ...rServiceDiscoverPrivateRequest.schema.json | 8 -- ...iceDiscoverPrivateResponse.jsonschema.json | 10 ++ ...ServiceDiscoverPrivateResponse.schema.json | 10 ++ app/controlplane/api/gen/openapi/openapi.yaml | 25 ++-- app/controlplane/cmd/wire_gen.go | 2 +- app/controlplane/internal/service/referrer.go | 44 ++---- app/controlplane/pkg/biz/referrer.go | 65 ++------- .../pkg/biz/referrer_integration_test.go | 93 ++++++------ .../pkg/biz/testhelpers/wire_gen.go | 2 +- pkg/policies/engine/builtins/rego_test.go | 132 ------------------ 20 files changed, 275 insertions(+), 453 deletions(-) delete mode 100644 app/cli/pkg/action/referrer_discover_test.go create mode 100644 app/controlplane/api/controlplane/v1/referrer_scope.go create mode 100644 app/controlplane/api/controlplane/v1/referrer_scope_test.go delete mode 100644 pkg/policies/engine/builtins/rego_test.go diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index e6f39ce61..38554729f 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -33,19 +33,14 @@ func newReferrerDiscoverCmd() *cobra.Command { Short: "(Preview) inspect pieces of evidence or artifacts stored through Chainloop", Long: `(Preview) inspect pieces of evidence or artifacts stored through Chainloop. -When logged in as a user, the discovery covers every organization you are a member of. -Set the organization explicitly with --org (or its environment variable) to search only that organization. -An API token always searches only its own organization.`, - RunE: func(cmd *cobra.Command, args []string) error { +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, } - res, err := action.NewReferrerDiscoverPrivate(ActionOpts, - // An explicitly selected organization scopes the discovery to it - action.WithCurrentOrgOnly(organizationExplicitlySelected(cmd)), - ).Run(context.Background(), digest, kind, pagination) + res, err := action.NewReferrerDiscoverPrivate(ActionOpts).Run(context.Background(), digest, kind, pagination) if err != nil { return err } diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index e8b45fb10..598681229 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -1408,9 +1408,7 @@ Synopsis (Preview) inspect pieces of evidence or artifacts stored through Chainloop. -When logged in as a user, the discovery covers every organization you are a member of. -Set the organization explicitly with --org (or its environment variable) to search only that organization. -An API token always searches only its own organization. +The discovery covers only the current organization. Select another organization with --org. ``` chainloop discover [flags] diff --git a/app/cli/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 0089dba48..44c903be1 100644 --- a/app/cli/pkg/action/referrer_discover.go +++ b/app/cli/pkg/action/referrer_discover.go @@ -24,19 +24,6 @@ import ( type ReferrerDiscover struct { cfg *ActionsOpts - // currentOrgOnly scopes the discovery to the current organization instead of - // every organization the user is a member of. - currentOrgOnly bool -} - -// ReferrerDiscoverOpt configures a ReferrerDiscover action. -type ReferrerDiscoverOpt func(*ReferrerDiscover) - -// WithCurrentOrgOnly scopes the discovery to the current organization. -func WithCurrentOrgOnly(currentOrgOnly bool) ReferrerDiscoverOpt { - return func(r *ReferrerDiscover) { - r.currentOrgOnly = currentOrgOnly - } } type ReferrerItem struct { @@ -54,18 +41,17 @@ type ReferrerDiscoverResult struct { NextCursor string `json:"nextCursor,omitempty"` } -func NewReferrerDiscoverPrivate(cfg *ActionsOpts, opts ...ReferrerDiscoverOpt) *ReferrerDiscover { - r := &ReferrerDiscover{cfg: cfg} - for _, opt := range opts { - opt(r) - } - - return r +func NewReferrerDiscoverPrivate(cfg *ActionsOpts) *ReferrerDiscover { + return &ReferrerDiscover{cfg} } func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string, p *PaginationOpts) (*ReferrerDiscoverResult, error) { client := pb.NewReferrerServiceClient(action.cfg.CPConnection) - resp, err := client.DiscoverPrivate(ctx, action.newRequest(digest, kind, p)) + resp, err := client.DiscoverPrivate(ctx, &pb.ReferrerServiceDiscoverPrivateRequest{ + Digest: digest, + Kind: kind, + Pagination: paginationOptsToPb(p), + }) if err != nil { return nil, err } @@ -73,15 +59,6 @@ func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string, p return newReferrerDiscoverResult(resp.Result, resp.GetPagination()), nil } -func (action *ReferrerDiscover) newRequest(digest, kind string, p *PaginationOpts) *pb.ReferrerServiceDiscoverPrivateRequest { - return &pb.ReferrerServiceDiscoverPrivateRequest{ - Digest: digest, - Kind: kind, - Pagination: paginationOptsToPb(p), - CurrentOrgOnly: action.currentOrgOnly, - } -} - func paginationOptsToPb(p *PaginationOpts) *pb.CursorPaginationRequest { if p == nil { return nil diff --git a/app/cli/pkg/action/referrer_discover_test.go b/app/cli/pkg/action/referrer_discover_test.go deleted file mode 100644 index 485f9a3e5..000000000 --- a/app/cli/pkg/action/referrer_discover_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package action - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestReferrerDiscoverNewRequest(t *testing.T) { - testCases := []struct { - name string - opts []ReferrerDiscoverOpt - pagination *PaginationOpts - wantCurrentOrgOnly bool - wantLimit int32 - }{ - { - name: "defaults to a cross-organization discovery", - }, - { - name: "scoped to the current organization", - opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(true)}, - wantCurrentOrgOnly: true, - }, - { - name: "explicitly not scoped", - opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(false)}, - }, - { - name: "keeps the pagination", - opts: []ReferrerDiscoverOpt{WithCurrentOrgOnly(true)}, - pagination: &PaginationOpts{Limit: 5, NextCursor: "cursor"}, - wantCurrentOrgOnly: true, - wantLimit: 5, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - req := NewReferrerDiscoverPrivate(&ActionsOpts{}, tc.opts...).newRequest("sha256:deadbeef", "CONTAINER_IMAGE", tc.pagination) - - assert.Equal(t, "sha256:deadbeef", req.GetDigest()) - assert.Equal(t, "CONTAINER_IMAGE", req.GetKind()) - assert.Equal(t, tc.wantCurrentOrgOnly, req.GetCurrentOrgOnly()) - assert.Equal(t, tc.wantLimit, req.GetPagination().GetLimit()) - }) - } -} diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index b068d3823..4e9b856b5 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -56,10 +56,6 @@ type ReferrerServiceDiscoverPrivateRequest struct { // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). // Requires project_name, since a version name is unique only within a project. ProjectVersion string `protobuf:"bytes,5,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` - // CurrentOrgOnly scopes the discovery to the current organization. By default, a user - // searches every organization they are a member of. An API token always searches only - // its own organization, so the field does not change the result for API tokens. - CurrentOrgOnly bool `protobuf:"varint,6,opt,name=current_org_only,json=currentOrgOnly,proto3" json:"current_org_only,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -129,22 +125,19 @@ func (x *ReferrerServiceDiscoverPrivateRequest) GetProjectVersion() string { return "" } -func (x *ReferrerServiceDiscoverPrivateRequest) GetCurrentOrgOnly() bool { - if x != nil { - return x.CurrentOrgOnly - } - return false -} - // ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method type ReferrerServiceDiscoverPrivateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Result is the discovered referrer item Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` // Pagination information for the references list - Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + // CurrentOrgScoped is true when the control plane limited the discovery to the current + // organization. Older control planes do not set it. With a user session they searched + // every organization of the user, so clients must not trust their results. + CurrentOrgScoped bool `protobuf:"varint,3,opt,name=current_org_scoped,json=currentOrgScoped,proto3" json:"current_org_scoped,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReferrerServiceDiscoverPrivateResponse) Reset() { @@ -191,6 +184,13 @@ func (x *ReferrerServiceDiscoverPrivateResponse) GetPagination() *CursorPaginati return nil } +func (x *ReferrerServiceDiscoverPrivateResponse) GetCurrentOrgScoped() bool { + if x != nil { + return x.CurrentOrgScoped + } + return false +} + // ReferrerItem represents a referrer object in the system type ReferrerItem struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -295,7 +295,7 @@ var File_controlplane_v1_referrer_proto protoreflect.FileDescriptor const file_controlplane_v1_referrer_proto_rawDesc = "" + "\n" + - "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\x9a\x04\n" + + "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xf0\x03\n" + "%ReferrerServiceDiscoverPrivateRequest\x12\x1f\n" + "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12H\n" + @@ -303,15 +303,15 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "pagination\x18\x03 \x01(\v2(.controlplane.v1.CursorPaginationRequestR\n" + "pagination\x12!\n" + "\fproject_name\x18\x04 \x01(\tR\vprojectName\x12'\n" + - "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion\x12(\n" + - "\x10current_org_only\x18\x06 \x01(\bR\x0ecurrentOrgOnly:\xfb\x01\x92AQ\n" + + "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion:\xfb\x01\x92AQ\n" + "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\xbaH\xa3\x01\x1a\xa0\x01\n" + - ".discover_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\x82\x02\n" + + ".discover_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\xb0\x02\n" + "&ReferrerServiceDiscoverPrivateResponse\x125\n" + "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result\x12I\n" + "\n" + "pagination\x18\x02 \x01(\v2).controlplane.v1.CursorPaginationResponseR\n" + - "pagination:V\x92AS\n" + + "pagination\x12,\n" + + "\x12current_org_scoped\x18\x03 \x01(\bR\x10currentOrgScoped:V\x92AS\n" + "Q*&ReferrerServiceDiscoverPrivateResponse2'Response for the DiscoverPrivate method\"\xc2\x04\n" + "\fReferrerItem\x12\x16\n" + "\x06digest\x18\x01 \x01(\tR\x06digest\x12\x12\n" + diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index c7fe130de..629739b0c 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -56,10 +56,6 @@ message ReferrerServiceDiscoverPrivateRequest { // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). // Requires project_name, since a version name is unique only within a project. string project_version = 5; - // CurrentOrgOnly scopes the discovery to the current organization. By default, a user - // searches every organization they are a member of. An API token always searches only - // its own organization, so the field does not change the result for API tokens. - bool current_org_only = 6; // project_version requires project_name (a version name is unique only within a project); // project_name on its own is allowed. @@ -83,6 +79,10 @@ message ReferrerServiceDiscoverPrivateResponse { ReferrerItem result = 1; // Pagination information for the references list CursorPaginationResponse pagination = 2; + // CurrentOrgScoped is true when the control plane limited the discovery to the current + // organization. Older control planes do not set it. With a user session they searched + // every organization of the user, so clients must not trust their results. + bool current_org_scoped = 3; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { diff --git a/app/controlplane/api/controlplane/v1/referrer_scope.go b/app/controlplane/api/controlplane/v1/referrer_scope.go new file mode 100644 index 000000000..4cce34299 --- /dev/null +++ b/app/controlplane/api/controlplane/v1/referrer_scope.go @@ -0,0 +1,45 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "context" + "errors" + + "google.golang.org/grpc" +) + +// ErrDiscoverScopeNotReported is returned when a control plane does not report that it limited +// the discovery to the current organization. With a user session, older control planes search +// every organization of the user, so their results must not be trusted. +var ErrDiscoverScopeNotReported = errors.New("the control plane did not limit the discovery to the current organization, upgrade the control plane") + +// DiscoverScopeUnaryClientInterceptor fails closed on discover responses that do not report the +// single-organization scope. Add it only to connections that authenticate with a user session: +// older control planes already limit the discovery of API tokens to the organization of the token. +func DiscoverScopeUnaryClientInterceptor() grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if err := invoker(ctx, method, req, reply, cc, opts...); err != nil { + return err + } + + if resp, ok := reply.(*ReferrerServiceDiscoverPrivateResponse); ok && !resp.GetCurrentOrgScoped() { + return ErrDiscoverScopeNotReported + } + + return nil + } +} diff --git a/app/controlplane/api/controlplane/v1/referrer_scope_test.go b/app/controlplane/api/controlplane/v1/referrer_scope_test.go new file mode 100644 index 000000000..75b3e4a48 --- /dev/null +++ b/app/controlplane/api/controlplane/v1/referrer_scope_test.go @@ -0,0 +1,81 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +func TestDiscoverScopeUnaryClientInterceptor(t *testing.T) { + errInvoke := errors.New("invoke failed") + + testCases := []struct { + name string + reply any + serverOut proto.Message + invokeErr error + wantErr error + }{ + { + name: "discover response that reports the scope", + reply: &ReferrerServiceDiscoverPrivateResponse{}, + serverOut: &ReferrerServiceDiscoverPrivateResponse{CurrentOrgScoped: true}, + }, + { + name: "discover response of an older control plane fails closed", + reply: &ReferrerServiceDiscoverPrivateResponse{}, + serverOut: &ReferrerServiceDiscoverPrivateResponse{}, + wantErr: ErrDiscoverScopeNotReported, + }, + { + name: "other methods are not checked", + reply: &ReferrerItem{}, + serverOut: &ReferrerItem{}, + }, + { + name: "invoke errors are returned as they are", + reply: &ReferrerServiceDiscoverPrivateResponse{}, + invokeErr: errInvoke, + wantErr: errInvoke, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + invoker := func(_ context.Context, _ string, _, reply any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + if tc.invokeErr != nil { + return tc.invokeErr + } + proto.Merge(reply.(proto.Message), tc.serverOut) + return nil + } + + err := DiscoverScopeUnaryClientInterceptor()(context.Background(), "/method", nil, tc.reply, nil, invoker) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 99127631c..b957f935b 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -28,12 +28,6 @@ export interface ReferrerServiceDiscoverPrivateRequest { * Requires project_name, since a version name is unique only within a project. */ projectVersion: string; - /** - * CurrentOrgOnly scopes the discovery to the current organization. By default, a user - * searches every organization they are a member of. An API token always searches only - * its own organization, so the field does not change the result for API tokens. - */ - currentOrgOnly: boolean; } /** ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method */ @@ -42,6 +36,12 @@ export interface ReferrerServiceDiscoverPrivateResponse { result?: ReferrerItem; /** Pagination information for the references list */ pagination?: CursorPaginationResponse; + /** + * CurrentOrgScoped is true when the control plane limited the discovery to the current + * organization. Older control planes do not set it. With a user session they searched + * every organization of the user, so clients must not trust their results. + */ + currentOrgScoped: boolean; } /** ReferrerItem represents a referrer object in the system */ @@ -73,7 +73,7 @@ export interface ReferrerItem_AnnotationsEntry { } function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { - return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "", currentOrgOnly: false }; + return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "" }; } export const ReferrerServiceDiscoverPrivateRequest = { @@ -93,9 +93,6 @@ export const ReferrerServiceDiscoverPrivateRequest = { if (message.projectVersion !== "") { writer.uint32(42).string(message.projectVersion); } - if (message.currentOrgOnly === true) { - writer.uint32(48).bool(message.currentOrgOnly); - } return writer; }, @@ -141,13 +138,6 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.projectVersion = reader.string(); continue; - case 6: - if (tag !== 48) { - break; - } - - message.currentOrgOnly = reader.bool(); - continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -164,7 +154,6 @@ export const ReferrerServiceDiscoverPrivateRequest = { pagination: isSet(object.pagination) ? CursorPaginationRequest.fromJSON(object.pagination) : undefined, projectName: isSet(object.projectName) ? String(object.projectName) : "", projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", - currentOrgOnly: isSet(object.currentOrgOnly) ? Boolean(object.currentOrgOnly) : false, }; }, @@ -176,7 +165,6 @@ export const ReferrerServiceDiscoverPrivateRequest = { (obj.pagination = message.pagination ? CursorPaginationRequest.toJSON(message.pagination) : undefined); message.projectName !== undefined && (obj.projectName = message.projectName); message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); - message.currentOrgOnly !== undefined && (obj.currentOrgOnly = message.currentOrgOnly); return obj; }, @@ -197,13 +185,12 @@ export const ReferrerServiceDiscoverPrivateRequest = { : undefined; message.projectName = object.projectName ?? ""; message.projectVersion = object.projectVersion ?? ""; - message.currentOrgOnly = object.currentOrgOnly ?? false; return message; }, }; function createBaseReferrerServiceDiscoverPrivateResponse(): ReferrerServiceDiscoverPrivateResponse { - return { result: undefined, pagination: undefined }; + return { result: undefined, pagination: undefined, currentOrgScoped: false }; } export const ReferrerServiceDiscoverPrivateResponse = { @@ -214,6 +201,9 @@ export const ReferrerServiceDiscoverPrivateResponse = { if (message.pagination !== undefined) { CursorPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); } + if (message.currentOrgScoped === true) { + writer.uint32(24).bool(message.currentOrgScoped); + } return writer; }, @@ -238,6 +228,13 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.pagination = CursorPaginationResponse.decode(reader, reader.uint32()); continue; + case 3: + if (tag !== 24) { + break; + } + + message.currentOrgScoped = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -251,6 +248,7 @@ export const ReferrerServiceDiscoverPrivateResponse = { return { result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined, pagination: isSet(object.pagination) ? CursorPaginationResponse.fromJSON(object.pagination) : undefined, + currentOrgScoped: isSet(object.currentOrgScoped) ? Boolean(object.currentOrgScoped) : false, }; }, @@ -259,6 +257,7 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.result !== undefined && (obj.result = message.result ? ReferrerItem.toJSON(message.result) : undefined); message.pagination !== undefined && (obj.pagination = message.pagination ? CursorPaginationResponse.toJSON(message.pagination) : undefined); + message.currentOrgScoped !== undefined && (obj.currentOrgScoped = message.currentOrgScoped); return obj; }, @@ -278,6 +277,7 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.pagination = (object.pagination !== undefined && object.pagination !== null) ? CursorPaginationResponse.fromPartial(object.pagination) : undefined; + message.currentOrgScoped = object.currentOrgScoped ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json index 13a2be3fe..6e59f03fc 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json @@ -4,10 +4,6 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", "patternProperties": { - "^(current_org_only)$": { - "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", - "type": "boolean" - }, "^(project_name)$": { "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", "type": "string" @@ -18,10 +14,6 @@ } }, "properties": { - "currentOrgOnly": { - "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", - "type": "boolean" - }, "digest": { "description": "Digest is the unique identifier of the referrer to discover", "minLength": 1, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json index 16a4e5fdd..e7901c82f 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json @@ -4,10 +4,6 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", "patternProperties": { - "^(currentOrgOnly)$": { - "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", - "type": "boolean" - }, "^(projectName)$": { "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", "type": "string" @@ -18,10 +14,6 @@ } }, "properties": { - "current_org_only": { - "description": "CurrentOrgOnly scopes the discovery to the current organization. By default, a user\n searches every organization they are a member of. An API token always searches only\n its own organization, so the field does not change the result for API tokens.", - "type": "boolean" - }, "digest": { "description": "Digest is the unique identifier of the referrer to discover", "minLength": 1, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json index da7c32ffc..5f5e17b26 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json @@ -3,7 +3,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", + "patternProperties": { + "^(current_org_scoped)$": { + "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", + "type": "boolean" + } + }, "properties": { + "currentOrgScoped": { + "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", + "type": "boolean" + }, "pagination": { "$ref": "controlplane.v1.CursorPaginationResponse.jsonschema.json", "description": "Pagination information for the references list" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json index 88440dcfa..92e4cbfb8 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json @@ -3,7 +3,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", + "patternProperties": { + "^(currentOrgScoped)$": { + "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", + "type": "boolean" + } + }, "properties": { + "current_org_scoped": { + "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", + "type": "boolean" + }, "pagination": { "$ref": "controlplane.v1.CursorPaginationResponse.schema.json", "description": "Pagination information for the references list" diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index d8117c98a..3d1ea7dec 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -74,19 +74,6 @@ paths: name: project_version schema: type: string - - description: >- - CurrentOrgOnly scopes the discovery to the current organization. By - default, a user - - searches every organization they are a member of. An API token - always searches only - - its own organization, so the field does not change the result for - API tokens. - in: query - name: current_org_only - schema: - type: boolean responses: '200': content: @@ -313,6 +300,7 @@ components: created_at: '2000-01-23T04:56:07.000+00:00' annotations: key: annotations + current_org_scoped: true pagination: next_cursor: next_cursor properties: @@ -320,6 +308,17 @@ components: $ref: '#/components/schemas/v1ReferrerItem' pagination: $ref: '#/components/schemas/v1CursorPaginationResponse' + current_org_scoped: + description: >- + CurrentOrgScoped is true when the control plane limited the + discovery to the current + + organization. Older control planes do not set it. With a user + session they searched + + every organization of the user, so clients must not trust their + results. + type: boolean title: ReferrerServiceDiscoverPrivateResponse type: object securitySchemes: diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 1775e07b6..135a5890f 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -125,7 +125,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr v2 := _wireValue casClientUseCase := biz.NewCASClientUseCase(casCredentialsUseCase, bootstrap_CASServer, logger, v2...) referrerRepo := data.NewReferrerRepo(dataData, workflowRepo, logger) - referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, membershipUseCase, logger) + referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, logger) if err != nil { cleanup3() cleanup2() diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 35c5224fa..acb1629aa 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -41,8 +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) - if err != nil { + if _, _, err := requireCurrentUserOrAPIToken(ctx); err != nil { return nil, err } @@ -64,37 +63,15 @@ 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 - var referrer *biz.StoredReferrer - var nextCursor string - if currentUser != nil { - // Optionally narrow the user's cross-organization discovery to the current organization. - if req.GetCurrentOrgOnly() { - orgUUID, parseErr := uuid.Parse(currentOrg.ID) - if parseErr != nil { - return nil, fmt.Errorf("invalid org UUID: %w", parseErr) - } - extraFilters = append(extraFilters, biz.WithOrgScope(orgUUID)) - } - - 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...) + 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, the same way for users and API tokens + 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) } @@ -102,6 +79,9 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return &pb.ReferrerServiceDiscoverPrivateResponse{ Result: bizReferrerToPb(referrer), Pagination: bizCursorToPb(nextCursor), + // Tell the client that the search covered only the current organization, + // so that it can reject the cross-organization results of older control planes + CurrentOrgScoped: true, }, nil } diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index a96d94ce4..95ffbeb11 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io" - "slices" "sort" "time" @@ -39,23 +38,21 @@ import ( var referrerTracer = otelx.Tracer("chainloop-controlplane", "biz/referrer") type ReferrerUseCase struct { - repo ReferrerRepo - membershipUseCase *MembershipUseCase - workflowRepo WorkflowRepo - logger *log.Helper + repo ReferrerRepo + workflowRepo WorkflowRepo + logger *log.Helper } -func NewReferrerUseCase(repo ReferrerRepo, wfRepo WorkflowRepo, membershipUseCase *MembershipUseCase, l log.Logger) (*ReferrerUseCase, error) { +func NewReferrerUseCase(repo ReferrerRepo, wfRepo WorkflowRepo, l log.Logger) (*ReferrerUseCase, error) { if l == nil { l = log.NewStdLogger(io.Discard) } logger := servicelogger.ScopedHelper(l, "biz/referrer") return &ReferrerUseCase{ - repo: repo, - membershipUseCase: membershipUseCase, - workflowRepo: wfRepo, - logger: logger, + repo: repo, + workflowRepo: wfRepo, + logger: logger, }, nil } @@ -105,9 +102,6 @@ type GetFromRootFilters struct { // Both must be set together (a version name is unique only within a project). ProjectName *string ProjectVersion *string - // OrgID narrows the discovery to a single organization. It only restricts the - // organizations allowed by the caller; it never grants access to another one. - OrgID *uuid.UUID } type GetFromRootFilter func(*GetFromRootFilters) @@ -127,14 +121,6 @@ func WithProjectScope(projectName, projectVersion string) func(*GetFromRootFilte } } -// WithOrgScope narrows the discovery to the given organization. A referrer is found -// only if the organization is also one of the organizations allowed for the caller. -func WithOrgScope(orgID uuid.UUID) func(*GetFromRootFilters) { - return func(o *GetFromRootFilters) { - o.OrgID = &orgID - } -} - // WithVisibleProjectIDs sets visible projects by org for organizations with RBAC enabled for the user (role is OrgMember) func WithVisibleProjectIDs(projectIDs map[OrgID][]ProjectID) func(*GetFromRootFilters) { return func(o *GetFromRootFilters) { @@ -172,29 +158,9 @@ func (s *ReferrerUseCase) ExtractAndPersist(ctx context.Context, att *dsse.Envel return nil } -// GetFromRootUser returns the referrer identified by the provided content digest, including its first-level references. +// 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 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) { - ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.GetFromRootUser") - defer span.End() - - userUUID, err := uuid.Parse(userID) - if err != nil { - return nil, "", NewErrInvalidUUID(err) - } - - userOrgs, 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...) -} - +// 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() @@ -208,19 +174,6 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri } filters = append(filters, extraFilters...) - opts := &GetFromRootFilters{} - for _, f := range filters { - f(opts) - } - - // An organization scope intersects with the allowed organizations, it never widens them. - if opts.OrgID != nil { - if !slices.Contains(orgIDs, *opts.OrgID) { - return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) - } - orgIDs = []uuid.UUID{*opts.OrgID} - } - ref, nextCursor, err := s.repo.GetFromRoot(ctx, digest, orgIDs, p, filters...) if err != nil { if errors.As(err, &ErrAmbiguousReferrer{}) { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index cd507936d..1ffe6bd4a 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -59,7 +59,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 +88,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 +151,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 +206,14 @@ 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) + 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,7 +230,7 @@ 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) @@ -240,13 +240,13 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("and now user2 has access to it since it has access to workflow2 in org2", 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) }) @@ -688,9 +688,9 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVisibility() { }) } -// An organization scope narrows a user's cross-organization discovery to a single -// organization, and never widens it to one the user is not a member of. -func (s *referrerIntegrationTestSuite) TestGetFromRootUserOrgScope() { +// Discovery never crosses organizations: a referrer is found only in the organization +// that holds it, even when the caller is also a member of other organizations. +func (s *referrerIntegrationTestSuite) TestGetFromRootSingleOrg() { ctx := context.Background() envelope, envBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") attDigest, _, err := v1.SHA256(bytes.NewReader(envBytes)) @@ -701,38 +701,23 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootUserOrgScope() { testCases := []struct { name string - userID string - filters []biz.GetFromRootFilter + orgID uuid.UUID wantFound bool }{ { - name: "no scope searches every organization of the user", - userID: s.user.ID, + name: "the organization that holds the referrer", + orgID: s.org1UUID, wantFound: true, }, { - name: "scoped to the organization that holds the referrer", - userID: s.user.ID, - filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org1UUID)}, - wantFound: true, - }, - { - name: "scoped to another organization of the user", - userID: s.user.ID, - filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org2UUID)}, - wantFound: false, - }, - { - name: "scoped to an organization the user is not a member of", - userID: s.user2.ID, - filters: []biz.GetFromRootFilter{biz.WithOrgScope(s.org1UUID)}, - wantFound: false, + name: "another organization", + orgID: s.org2UUID, }, } for _, tc := range testCases { s.Run(tc.name, func() { - got, _, err := s.Referrer.GetFromRootUser(ctx, digest, "", tc.userID, nil, tc.filters...) + got, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{tc.orgID}, nil, nil) if !tc.wantFound { s.True(biz.IsNotFound(err), "expected not found, got %v", err) s.Nil(got) diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 75bbabf80..d9a6e661f 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -149,7 +149,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t return nil, nil, err } referrerRepo := data.NewReferrerRepo(dataData, workflowRepo, logger) - referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, membershipUseCase, logger) + referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, logger) if err != nil { cleanup() return nil, nil, err diff --git a/pkg/policies/engine/builtins/rego_test.go b/pkg/policies/engine/builtins/rego_test.go deleted file mode 100644 index 735d564c8..000000000 --- a/pkg/policies/engine/builtins/rego_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package builtins - -import ( - "context" - "net" - "sync" - "testing" - - v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" -) - -const containerImageKind = "CONTAINER_IMAGE" - -// fakeReferrerService records the discover requests it receives. -type fakeReferrerService struct { - v1.UnimplementedReferrerServiceServer - mu sync.Mutex - requests []*v1.ReferrerServiceDiscoverPrivateRequest -} - -func (f *fakeReferrerService) DiscoverPrivate(_ context.Context, req *v1.ReferrerServiceDiscoverPrivateRequest) (*v1.ReferrerServiceDiscoverPrivateResponse, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.requests = append(f.requests, req) - - return &v1.ReferrerServiceDiscoverPrivateResponse{ - Result: &v1.ReferrerItem{Digest: req.GetDigest(), Kind: containerImageKind}, - }, nil -} - -func (f *fakeReferrerService) lastRequest() *v1.ReferrerServiceDiscoverPrivateRequest { - f.mu.Lock() - defer f.mu.Unlock() - if len(f.requests) == 0 { - return nil - } - - return f.requests[len(f.requests)-1] -} - -func newFakeReferrerConn(t *testing.T) (*grpc.ClientConn, *fakeReferrerService) { - t.Helper() - - lis := bufconn.Listen(1024 * 1024) - srv := grpc.NewServer() - fake := &fakeReferrerService{} - v1.RegisterReferrerServiceServer(srv, fake) - go func() { _ = srv.Serve(lis) }() - t.Cleanup(srv.Stop) - - conn, err := grpc.NewClient("passthrough:///bufnet", - grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - require.NoError(t, err) - t.Cleanup(func() { _ = conn.Close() }) - - return conn, fake -} - -func TestDiscoverBuiltins(t *testing.T) { - conn, fake := newFakeReferrerConn(t) - require.NoError(t, RegisterDiscoverBuiltin(conn)) - - testCases := []struct { - name string - query string - wantKind string - wantCurrentOrgOnly bool - }{ - { - name: "discover searches every organization", - query: `chainloop.discover("sha256:deadbeef", "")`, - }, - { - name: "discover forwards the kind", - query: `chainloop.discover("sha256:deadbeef", "CONTAINER_IMAGE")`, - wantKind: containerImageKind, - }, - { - name: "discover_current_org scopes to the current organization", - query: `chainloop.discover_current_org("sha256:deadbeef", "")`, - wantCurrentOrgOnly: true, - }, - { - name: "discover_current_org forwards the kind", - query: `chainloop.discover_current_org("sha256:deadbeef", "CONTAINER_IMAGE")`, - wantKind: containerImageKind, - wantCurrentOrgOnly: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - rs, err := rego.New(rego.Query(tc.query)).Eval(context.Background()) - require.NoError(t, err) - require.Len(t, rs, 1) - require.Len(t, rs[0].Expressions, 1) - - got, ok := rs[0].Expressions[0].Value.(map[string]any) - require.True(t, ok, "unexpected result type %T", rs[0].Expressions[0].Value) - assert.Equal(t, "sha256:deadbeef", got["digest"]) - - req := fake.lastRequest() - require.NotNil(t, req) - assert.Equal(t, "sha256:deadbeef", req.GetDigest()) - assert.Equal(t, tc.wantKind, req.GetKind()) - assert.Equal(t, tc.wantCurrentOrgOnly, req.GetCurrentOrgOnly()) - }) - } -} From 5caf663826b7903fb58b96b821cddbbd2ab6cde5 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 14:48:09 +0100 Subject: [PATCH 4/8] fix(discover): apply only the user's project visibility in the current organization A referrer lists its projects of all organizations. Building a user's visibility from the cached memberships put the user's projects of every organization under the current one, so a visible project in another organization could make a referrer of the current organization visible. Users now get the visibility of their memberships in the current organization only. Also describe the discover RPC as current-organization only. Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- .../api/controlplane/v1/referrer.pb.go | 6 +- .../api/controlplane/v1/referrer.proto | 4 +- .../api/controlplane/v1/referrer_grpc.pb.go | 4 +- .../api/controlplane/v1/referrer_http.pb.go | 2 +- .../gen/frontend/controlplane/v1/referrer.ts | 2 +- app/controlplane/api/gen/openapi/openapi.yaml | 4 +- app/controlplane/cmd/wire_gen.go | 2 +- app/controlplane/internal/service/referrer.go | 18 +++- app/controlplane/pkg/biz/referrer.go | 55 +++++++++-- .../pkg/biz/referrer_user_integration_test.go | 94 +++++++++++++++++++ .../pkg/biz/testhelpers/wire_gen.go | 2 +- 11 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 app/controlplane/pkg/biz/referrer_user_integration_test.go diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 4e9b856b5..43ea6250b 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -330,9 +330,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 629739b0c..ca7a9072b 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 b957f935b..ef64031fd 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -608,7 +608,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 3d1ea7dec..a36ee20b6 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/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 135a5890f..1775e07b6 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -125,7 +125,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr v2 := _wireValue casClientUseCase := biz.NewCASClientUseCase(casCredentialsUseCase, bootstrap_CASServer, logger, v2...) referrerRepo := data.NewReferrerRepo(dataData, workflowRepo, logger) - referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, logger) + referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, membershipUseCase, logger) if err != nil { cleanup3() cleanup2() diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index acb1629aa..ea1206ec5 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -41,7 +41,8 @@ func NewReferrerService(uc *biz.ReferrerUseCase, opts ...NewOpt) *ReferrerServic } func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerServiceDiscoverPrivateRequest) (*pb.ReferrerServiceDiscoverPrivateResponse, error) { - if _, _, err := requireCurrentUserOrAPIToken(ctx); err != nil { + currentUser, _, err := requireCurrentUserOrAPIToken(ctx) + if err != nil { return nil, err } @@ -68,10 +69,17 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return nil, fmt.Errorf("invalid org UUID: %w", err) } - // Discovery is always limited to the current organization, with the project visibility - // of the caller, the same way for users and API tokens - referrer, nextCursor, err := s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, - s.rbacScopesForOrg(ctx, orgUUID).ProjectIDsByOrg(), paginationOpts, extraFilters...) + // Discovery is always limited to the current organization, with the project visibility of + // the caller in that organization. A user's visibility comes from the memberships of the user + // in that organization only. An API token reaches only projects of its own organization. + var referrer *biz.StoredReferrer + var nextCursor string + if currentUser != nil { + 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 95ffbeb11..dde43d520 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "slices" "sort" "time" @@ -38,21 +39,23 @@ import ( var referrerTracer = otelx.Tracer("chainloop-controlplane", "biz/referrer") type ReferrerUseCase struct { - repo ReferrerRepo - workflowRepo WorkflowRepo - logger *log.Helper + repo ReferrerRepo + membershipUseCase *MembershipUseCase + workflowRepo WorkflowRepo + logger *log.Helper } -func NewReferrerUseCase(repo ReferrerRepo, wfRepo WorkflowRepo, l log.Logger) (*ReferrerUseCase, error) { +func NewReferrerUseCase(repo ReferrerRepo, wfRepo WorkflowRepo, membershipUseCase *MembershipUseCase, l log.Logger) (*ReferrerUseCase, error) { if l == nil { l = log.NewStdLogger(io.Discard) } logger := servicelogger.ScopedHelper(l, "biz/referrer") return &ReferrerUseCase{ - repo: repo, - workflowRepo: wfRepo, - logger: logger, + repo: repo, + membershipUseCase: membershipUseCase, + workflowRepo: wfRepo, + logger: logger, }, nil } @@ -158,6 +161,38 @@ func (s *ReferrerUseCase) ExtractAndPersist(ctx context.Context, att *dsse.Envel return nil } +// GetFromRootUser returns the referrer identified by the provided content digest, including its first-level references. +// The discovery is limited to the given organization, which the user must be a member of, with the project +// visibility that the user has in that 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() + + userUUID, err := uuid.Parse(userID) + if err != nil { + return nil, "", NewErrInvalidUUID(err) + } + + userOrgs, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) + if err != nil { + return nil, "", err + } + + if !slices.Contains(userOrgs, orgID) { + return nil, "", newReferrerNotFoundErr(digest) + } + + // 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 = map[OrgID][]ProjectID{orgID: scope.ProjectIDs} + } + + 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. @@ -182,12 +217,16 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri return nil, "", fmt.Errorf("getting referrer from root: %w", err) } else if ref == nil { - return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) + return nil, "", newReferrerNotFoundErr(digest) } return ref, nextCursor, nil } +func newReferrerNotFoundErr(digest string) error { + return NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) +} + const ( // ReferrerAttestationType is the kind of the referrer that represents an attestation. ReferrerAttestationType = "ATTESTATION" diff --git a/app/controlplane/pkg/biz/referrer_user_integration_test.go b/app/controlplane/pkg/biz/referrer_user_integration_test.go new file mode 100644 index 000000000..86a6c9876 --- /dev/null +++ b/app/controlplane/pkg/biz/referrer_user_integration_test.go @@ -0,0 +1,94 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz_test + +import ( + "bytes" + "context" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// 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, + }, + { + name: "an organization the user is not a member of", + userID: s.user2.ID, + orgID: s.org1UUID, + }, + { + 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/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index d9a6e661f..75bbabf80 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -149,7 +149,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t return nil, nil, err } referrerRepo := data.NewReferrerRepo(dataData, workflowRepo, logger) - referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, logger) + referrerUseCase, err := biz.NewReferrerUseCase(referrerRepo, workflowRepo, membershipUseCase, logger) if err != nil { cleanup() return nil, nil, err From 7db4d5fef091c7dc162e7d18f9b450b68e4c0b1a Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 15:33:30 +0100 Subject: [PATCH 5/8] refactor(discover): server-only single-org discovery, per review Per the review of the spec: discovery resolves the organization like any other operation, through the org middleware (instance admins included), and the change is control plane behavior only. Drop the scope report in the response and the CLI check of it. The spec drops R-002, R-003, D-004 and D-006, and D-002 and D-003 say how the organization is resolved and why no client changes. Simplify the tests and the scope narrowing. Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- .../api/controlplane/v1/referrer.pb.go | 22 +---- .../api/controlplane/v1/referrer.proto | 4 - .../api/controlplane/v1/referrer_scope.go | 45 --------- .../controlplane/v1/referrer_scope_test.go | 81 ---------------- .../gen/frontend/controlplane/v1/referrer.ts | 21 +---- ...iceDiscoverPrivateResponse.jsonschema.json | 10 -- ...ServiceDiscoverPrivateResponse.schema.json | 10 -- app/controlplane/api/gen/openapi/openapi.yaml | 12 --- app/controlplane/internal/service/referrer.go | 6 +- app/controlplane/pkg/biz/referrer.go | 20 ++-- .../pkg/biz/referrer_integration_test.go | 51 +++++++--- .../pkg/biz/referrer_user_integration_test.go | 94 ------------------- docs/specs/issue-3776-single-org-discovery.md | 63 ++++--------- 13 files changed, 70 insertions(+), 369 deletions(-) delete mode 100644 app/controlplane/api/controlplane/v1/referrer_scope.go delete mode 100644 app/controlplane/api/controlplane/v1/referrer_scope_test.go delete mode 100644 app/controlplane/pkg/biz/referrer_user_integration_test.go diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 43ea6250b..48066e391 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -131,13 +131,9 @@ type ReferrerServiceDiscoverPrivateResponse struct { // Result is the discovered referrer item Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` // Pagination information for the references list - Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` - // CurrentOrgScoped is true when the control plane limited the discovery to the current - // organization. Older control planes do not set it. With a user session they searched - // every organization of the user, so clients must not trust their results. - CurrentOrgScoped bool `protobuf:"varint,3,opt,name=current_org_scoped,json=currentOrgScoped,proto3" json:"current_org_scoped,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReferrerServiceDiscoverPrivateResponse) Reset() { @@ -184,13 +180,6 @@ func (x *ReferrerServiceDiscoverPrivateResponse) GetPagination() *CursorPaginati return nil } -func (x *ReferrerServiceDiscoverPrivateResponse) GetCurrentOrgScoped() bool { - if x != nil { - return x.CurrentOrgScoped - } - return false -} - // ReferrerItem represents a referrer object in the system type ReferrerItem struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -305,13 +294,12 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "\fproject_name\x18\x04 \x01(\tR\vprojectName\x12'\n" + "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion:\xfb\x01\x92AQ\n" + "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\xbaH\xa3\x01\x1a\xa0\x01\n" + - ".discover_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\xb0\x02\n" + + ".discover_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\x82\x02\n" + "&ReferrerServiceDiscoverPrivateResponse\x125\n" + "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result\x12I\n" + "\n" + "pagination\x18\x02 \x01(\v2).controlplane.v1.CursorPaginationResponseR\n" + - "pagination\x12,\n" + - "\x12current_org_scoped\x18\x03 \x01(\bR\x10currentOrgScoped:V\x92AS\n" + + "pagination:V\x92AS\n" + "Q*&ReferrerServiceDiscoverPrivateResponse2'Response for the DiscoverPrivate method\"\xc2\x04\n" + "\fReferrerItem\x12\x16\n" + "\x06digest\x18\x01 \x01(\tR\x06digest\x12\x12\n" + diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index ca7a9072b..d6d40d3ce 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -79,10 +79,6 @@ message ReferrerServiceDiscoverPrivateResponse { ReferrerItem result = 1; // Pagination information for the references list CursorPaginationResponse pagination = 2; - // CurrentOrgScoped is true when the control plane limited the discovery to the current - // organization. Older control planes do not set it. With a user session they searched - // every organization of the user, so clients must not trust their results. - bool current_org_scoped = 3; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { diff --git a/app/controlplane/api/controlplane/v1/referrer_scope.go b/app/controlplane/api/controlplane/v1/referrer_scope.go deleted file mode 100644 index 4cce34299..000000000 --- a/app/controlplane/api/controlplane/v1/referrer_scope.go +++ /dev/null @@ -1,45 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package v1 - -import ( - "context" - "errors" - - "google.golang.org/grpc" -) - -// ErrDiscoverScopeNotReported is returned when a control plane does not report that it limited -// the discovery to the current organization. With a user session, older control planes search -// every organization of the user, so their results must not be trusted. -var ErrDiscoverScopeNotReported = errors.New("the control plane did not limit the discovery to the current organization, upgrade the control plane") - -// DiscoverScopeUnaryClientInterceptor fails closed on discover responses that do not report the -// single-organization scope. Add it only to connections that authenticate with a user session: -// older control planes already limit the discovery of API tokens to the organization of the token. -func DiscoverScopeUnaryClientInterceptor() grpc.UnaryClientInterceptor { - return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - if err := invoker(ctx, method, req, reply, cc, opts...); err != nil { - return err - } - - if resp, ok := reply.(*ReferrerServiceDiscoverPrivateResponse); ok && !resp.GetCurrentOrgScoped() { - return ErrDiscoverScopeNotReported - } - - return nil - } -} diff --git a/app/controlplane/api/controlplane/v1/referrer_scope_test.go b/app/controlplane/api/controlplane/v1/referrer_scope_test.go deleted file mode 100644 index 75b3e4a48..000000000 --- a/app/controlplane/api/controlplane/v1/referrer_scope_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package v1 - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "google.golang.org/grpc" - "google.golang.org/protobuf/proto" -) - -func TestDiscoverScopeUnaryClientInterceptor(t *testing.T) { - errInvoke := errors.New("invoke failed") - - testCases := []struct { - name string - reply any - serverOut proto.Message - invokeErr error - wantErr error - }{ - { - name: "discover response that reports the scope", - reply: &ReferrerServiceDiscoverPrivateResponse{}, - serverOut: &ReferrerServiceDiscoverPrivateResponse{CurrentOrgScoped: true}, - }, - { - name: "discover response of an older control plane fails closed", - reply: &ReferrerServiceDiscoverPrivateResponse{}, - serverOut: &ReferrerServiceDiscoverPrivateResponse{}, - wantErr: ErrDiscoverScopeNotReported, - }, - { - name: "other methods are not checked", - reply: &ReferrerItem{}, - serverOut: &ReferrerItem{}, - }, - { - name: "invoke errors are returned as they are", - reply: &ReferrerServiceDiscoverPrivateResponse{}, - invokeErr: errInvoke, - wantErr: errInvoke, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - invoker := func(_ context.Context, _ string, _, reply any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { - if tc.invokeErr != nil { - return tc.invokeErr - } - proto.Merge(reply.(proto.Message), tc.serverOut) - return nil - } - - err := DiscoverScopeUnaryClientInterceptor()(context.Background(), "/method", nil, tc.reply, nil, invoker) - if tc.wantErr != nil { - assert.ErrorIs(t, err, tc.wantErr) - return - } - - assert.NoError(t, err) - }) - } -} diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index ef64031fd..84017d221 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -36,12 +36,6 @@ export interface ReferrerServiceDiscoverPrivateResponse { result?: ReferrerItem; /** Pagination information for the references list */ pagination?: CursorPaginationResponse; - /** - * CurrentOrgScoped is true when the control plane limited the discovery to the current - * organization. Older control planes do not set it. With a user session they searched - * every organization of the user, so clients must not trust their results. - */ - currentOrgScoped: boolean; } /** ReferrerItem represents a referrer object in the system */ @@ -190,7 +184,7 @@ export const ReferrerServiceDiscoverPrivateRequest = { }; function createBaseReferrerServiceDiscoverPrivateResponse(): ReferrerServiceDiscoverPrivateResponse { - return { result: undefined, pagination: undefined, currentOrgScoped: false }; + return { result: undefined, pagination: undefined }; } export const ReferrerServiceDiscoverPrivateResponse = { @@ -201,9 +195,6 @@ export const ReferrerServiceDiscoverPrivateResponse = { if (message.pagination !== undefined) { CursorPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); } - if (message.currentOrgScoped === true) { - writer.uint32(24).bool(message.currentOrgScoped); - } return writer; }, @@ -228,13 +219,6 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.pagination = CursorPaginationResponse.decode(reader, reader.uint32()); continue; - case 3: - if (tag !== 24) { - break; - } - - message.currentOrgScoped = reader.bool(); - continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -248,7 +232,6 @@ export const ReferrerServiceDiscoverPrivateResponse = { return { result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined, pagination: isSet(object.pagination) ? CursorPaginationResponse.fromJSON(object.pagination) : undefined, - currentOrgScoped: isSet(object.currentOrgScoped) ? Boolean(object.currentOrgScoped) : false, }; }, @@ -257,7 +240,6 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.result !== undefined && (obj.result = message.result ? ReferrerItem.toJSON(message.result) : undefined); message.pagination !== undefined && (obj.pagination = message.pagination ? CursorPaginationResponse.toJSON(message.pagination) : undefined); - message.currentOrgScoped !== undefined && (obj.currentOrgScoped = message.currentOrgScoped); return obj; }, @@ -277,7 +259,6 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.pagination = (object.pagination !== undefined && object.pagination !== null) ? CursorPaginationResponse.fromPartial(object.pagination) : undefined; - message.currentOrgScoped = object.currentOrgScoped ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json index 5f5e17b26..da7c32ffc 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json @@ -3,17 +3,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", - "patternProperties": { - "^(current_org_scoped)$": { - "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", - "type": "boolean" - } - }, "properties": { - "currentOrgScoped": { - "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", - "type": "boolean" - }, "pagination": { "$ref": "controlplane.v1.CursorPaginationResponse.jsonschema.json", "description": "Pagination information for the references list" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json index 92e4cbfb8..88440dcfa 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json @@ -3,17 +3,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", - "patternProperties": { - "^(currentOrgScoped)$": { - "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", - "type": "boolean" - } - }, "properties": { - "current_org_scoped": { - "description": "CurrentOrgScoped is true when the control plane limited the discovery to the current\n organization. Older control planes do not set it. With a user session they searched\n every organization of the user, so clients must not trust their results.", - "type": "boolean" - }, "pagination": { "$ref": "controlplane.v1.CursorPaginationResponse.schema.json", "description": "Pagination information for the references list" diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index a36ee20b6..ce80540cb 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -298,7 +298,6 @@ components: created_at: '2000-01-23T04:56:07.000+00:00' annotations: key: annotations - current_org_scoped: true pagination: next_cursor: next_cursor properties: @@ -306,17 +305,6 @@ components: $ref: '#/components/schemas/v1ReferrerItem' pagination: $ref: '#/components/schemas/v1CursorPaginationResponse' - current_org_scoped: - description: >- - CurrentOrgScoped is true when the control plane limited the - discovery to the current - - organization. Older control planes do not set it. With a user - session they searched - - every organization of the user, so clients must not trust their - results. - type: boolean title: ReferrerServiceDiscoverPrivateResponse type: object securitySchemes: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index ea1206ec5..3f8ef65d3 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -70,8 +70,7 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS } // Discovery is always limited to the current organization, with the project visibility of - // the caller in that organization. A user's visibility comes from the memberships of the user - // in that organization only. An API token reaches only projects of its own organization. + // the caller in it (see GetFromRootUser for users) var referrer *biz.StoredReferrer var nextCursor string if currentUser != nil { @@ -87,9 +86,6 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return &pb.ReferrerServiceDiscoverPrivateResponse{ Result: bizReferrerToPb(referrer), Pagination: bizCursorToPb(nextCursor), - // Tell the client that the search covered only the current organization, - // so that it can reject the cross-organization results of older control planes - CurrentOrgScoped: true, }, nil } diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index dde43d520..e3481c03e 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io" - "slices" "sort" "time" @@ -162,8 +161,9 @@ 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. -// The discovery is limited to the given organization, which the user must be a member of, with the project -// visibility that the user has in that organization. +// 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() @@ -173,21 +173,17 @@ 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 } - if !slices.Contains(userOrgs, orgID) { - return nil, "", newReferrerNotFoundErr(digest) - } - // 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 = map[OrgID][]ProjectID{orgID: scope.ProjectIDs} + projectIDs = RBACScopes{orgID: scope}.ProjectIDsByOrg() } return s.GetFromRoot(ctx, digest, rootKind, []uuid.UUID{orgID}, projectIDs, p, extraFilters...) @@ -217,16 +213,12 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri return nil, "", fmt.Errorf("getting referrer from root: %w", err) } else if ref == nil { - return nil, "", newReferrerNotFoundErr(digest) + return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) } return ref, nextCursor, nil } -func newReferrerNotFoundErr(digest string) error { - return NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) -} - const ( // ReferrerAttestationType is the kind of the referrer that represents an attestation. ReferrerAttestationType = "ATTESTATION" diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 1ffe6bd4a..091577ac2 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" @@ -211,8 +212,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { 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 + 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) @@ -237,7 +237,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { 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.GetFromRoot(ctx, h.String(), "", []uuid.UUID{s.org2UUID}, nil, nil) @@ -688,36 +688,64 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVisibility() { }) } -// Discovery never crosses organizations: a referrer is found only in the organization -// that holds it, even when the caller is also a member of other organizations. -func (s *referrerIntegrationTestSuite) TestGetFromRootSingleOrg() { +// 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 attestation lives in org1 only + // 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: "the organization that holds the referrer", + name: "a member of the organization", + userID: s.user.ID, orgID: s.org1UUID, wantFound: true, }, { - name: "another organization", - orgID: s.org2UUID, + // 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.GetFromRoot(ctx, digest, "", []uuid.UUID{tc.orgID}, nil, nil) + 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) @@ -726,7 +754,6 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootSingleOrg() { s.Require().NoError(err) s.Equal(digest, got.Digest) - s.Equal([]uuid.UUID{s.org1UUID}, got.OrgIDs) }) } } diff --git a/app/controlplane/pkg/biz/referrer_user_integration_test.go b/app/controlplane/pkg/biz/referrer_user_integration_test.go deleted file mode 100644 index 86a6c9876..000000000 --- a/app/controlplane/pkg/biz/referrer_user_integration_test.go +++ /dev/null @@ -1,94 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package biz_test - -import ( - "bytes" - "context" - - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/uuid" - "github.com/stretchr/testify/require" -) - -// 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, - }, - { - name: "an organization the user is not a member of", - userID: s.user2.ID, - orgID: s.org1UUID, - }, - { - 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/docs/specs/issue-3776-single-org-discovery.md b/docs/specs/issue-3776-single-org-discovery.md index 6214ca063..1a61669d3 100644 --- a/docs/specs/issue-3776-single-org-discovery.md +++ b/docs/specs/issue-3776-single-org-discovery.md @@ -25,7 +25,7 @@ When your code goes against this spec, say so in your PR. Supersede the entry wh ## 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. This applies to all clients: the discover API, the `chainloop discover` command, the policy functions, and the web UI. The control plane reports the organization that it searched. The CLI and the policy engine, with a user session, reject a response without that report. Thus an older control plane cannot give a client results from other organizations without an error. +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 @@ -37,8 +37,8 @@ Referrer discovery finds an artifact or a piece of evidence by its digest. It al - 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. -- Goal: a new client never gets results from other organizations from an older control plane without an error. - 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. @@ -48,22 +48,15 @@ Referrer discovery finds an artifact or a piece of evidence by its digest. It al 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-002: The control plane reports the searched organization -Each successful discovery response MUST tell the client that the control plane limited the search to the current organization. - -### R-003: The CLI and the policy engine fail closed -With a user session, the CLI and the policy engine MUST return an error for a discovery response without the report of R-002. The error MUST tell the user to upgrade the control plane. With an API token, they MUST NOT return an error for this reason. -- Done when: a control plane does not report the scope. With a user session, the CLI and the policy engine return the upgrade error. With an API token against the same control plane, they return the result. - ### 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. +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 format does not change. A response gets one new item of data, and old clients ignore it. +- 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. @@ -72,25 +65,18 @@ For the same organization, digest, and project visibility, discovery MUST return 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. For a user session, it makes sure that the user is a member of that organization, as for all other operations. It searches the graph in that organization only, with the project visibility of the caller. Then it adds a report to the response: the search covered only the current organization. - -With a user session, the CLI and the policy engine check for that report in each response. When it is missing, the control plane is older than this change and searched all organizations of the user. They discard the result and return an error that asks for an upgrade. With an API token they do not check, because an older control plane already limited that search to one organization. +The control plane finds the current organization of the request as for every other operation. The organization comes from the organization header of the request or from the default membership of the user. 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 use the same path. Policies do not change. The web UI ships with its control plane, so it does not need the check. It shows the graph of the current organization only. +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 (current organization selected) - CP->>CP: Find the current organization and check the membership - CP->>CP: Search the graph in that organization, with project visibility - CP-->>C: Result, with "limited to current organization" - alt User session and the report is missing (older control plane) - C-->>C: Discard the result, return "upgrade the control plane" - else Report present, or API token - C-->>C: Return the result - end + 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 @@ -104,24 +90,16 @@ No option searches more than one organization. This replaces the first design of - 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 uses the organization that the control plane selects for every other operation of the request. It does not take an organization of its own. +Discovery works as every other API operation. The organization comes from the organization header of the request, or from the default membership of the user. 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: drafting +- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4468337274) -### D-003: The control plane reports the scope, and the client fails closed -The response says that the search covered only the current organization. A client that expects the limit and does not get the report returns an error. -- Why: an older control plane ignores data it does not know. It returns results of all organizations without an error. Only a report from the control plane lets the client detect this. +### 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: - - Accept the old behavior until the operator upgrades the control plane: a policy can pass or fail for the wrong reason without a sign. - - Filter the results in the client: the response does not carry the organization of every item. The filter would also duplicate the access rules of the control plane. -- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4466205569) - -### D-004: Only user-session clients check the report -A client with an API token does not reject a response without the report. -- Why: an older control plane already limits API-token discovery to one organization. A check there would break CI pipelines against older control planes. It would also break policy evaluation there, for no gain. -- Alternatives considered: - - Check every response: simpler, but a new CLI or policy engine stops working with every older control plane. -- Source: drafting + - 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. @@ -130,11 +108,6 @@ A client with an API token does not reject a response without the report. - Keep the second function as an alias: it adds a name that does nothing different. - Source: drafting -### D-006: Spec and implementation in the same PR -The spec ships in the implementation PR, and the PR changes to match it. -- Why: the reviewers asked for the spec on the implementation PR, and the change is small. -- Source: [PR #3777 review](https://github.com/chainloop-dev/chainloop/pull/3777#discussion_r4467148631) - ## Open Questions No open questions. @@ -144,5 +117,5 @@ No open questions. | 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`. | -| A new CLI with a user session against an older control plane cannot use discovery. | The error tells the user to upgrade the control plane. API-token pipelines are not affected (D-004). | +| 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. | From a7f29faa78f2c8f7356c13fb1414756c5842c294 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 15:37:35 +0100 Subject: [PATCH 6/8] spec: issue-3776 describe how API tokens resolve the organization Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 852787b5-3d54-4660-9a46-2d3323b45ca4 --- docs/specs/issue-3776-single-org-discovery.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/issue-3776-single-org-discovery.md b/docs/specs/issue-3776-single-org-discovery.md index 1a61669d3..da2b00c24 100644 --- a/docs/specs/issue-3776-single-org-discovery.md +++ b/docs/specs/issue-3776-single-org-discovery.md @@ -65,7 +65,7 @@ For the same organization, digest, and project visibility, discovery MUST return 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. The organization comes from the organization header of the request or from the default membership of the user. 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 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. @@ -90,7 +90,7 @@ No option searches more than one organization. This replaces the first design of - 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. The organization comes from the organization header of the request, or from the default membership of the user. 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. +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) From e92998f6460b2c11c66e5e247d9c2640e5da6366 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 14:49:15 +0100 Subject: [PATCH 7/8] fix(discover): evaluate referrer project visibility per organization Defense in depth for the referrer visibility check. The check compared the referrer's projects of every organization with the visible projects of an allowed organization. A caller that passed a visible list holding projects of other organizations, like a flat list of a user's memberships, could make a referrer of the current organization visible through a project of another organization. The check now compares only the referrer's projects of each organization with the visible projects of that same organization, like the SQL project visibility predicate already does, so it no longer depends on every caller building a per-organization list. Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 6d036d75-8fce-4944-9cfe-7442ac094513 --- .../pkg/biz/referrer_integration_test.go | 16 +++++ app/controlplane/pkg/data/referrer.go | 33 ++++++---- app/controlplane/pkg/data/referrer_test.go | 61 +++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 091577ac2..802e649c1 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -686,6 +686,22 @@ 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 diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 5866bde9c..400090ac0 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,27 @@ 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) + projectsByOrg := make(map[uuid.UUID][]uuid.UUID, 0) for _, wf := range root.Edges.Workflows { workflowIDs = append(workflowIDs, wf.ID) - if _, ok := orgsMap[wf.OrganizationID]; !ok { + if _, ok := projectsByOrg[wf.OrganizationID]; !ok { orgIDs = append(orgIDs, wf.OrganizationID) } - if _, ok := projectIDs[wf.ProjectID]; !ok { - projectIDs[wf.ProjectID] = true + if !slices.Contains(projectsByOrg[wf.OrganizationID], wf.ProjectID) { + projectsByOrg[wf.OrganizationID] = append(projectsByOrg[wf.OrganizationID], wf.ProjectID) } - orgsMap[wf.OrganizationID] = struct{}{} + projectIDs[wf.ProjectID] = true } 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..5f6a0d652 100644 --- a/app/controlplane/pkg/data/referrer_test.go +++ b/app/controlplane/pkg/data/referrer_test.go @@ -226,3 +226,64 @@ 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)) + }) + } +} From ecf740b4895e125468b3a5facc59a1bf4586f121 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 4 Nov 2026 21:22:24 +0100 Subject: [PATCH 8/8] perf(discover): deduplicate referrer projects per organization with a set Grouping the projects of a referrer by organization used slices.Contains, which is quadratic in the number of distinct projects of a shared referrer. Use a set per organization and build the slices after the scan. Refs #3776 Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 6d036d75-8fce-4944-9cfe-7442ac094513 --- app/controlplane/pkg/data/referrer.go | 17 ++++-- app/controlplane/pkg/data/referrer_test.go | 62 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 400090ac0..56e277ef8 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -543,18 +543,25 @@ func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) map[uuid. workflowIDs := make([]uuid.UUID, 0, len(root.Edges.Workflows)) projectIDs := make(map[uuid.UUID]bool, 0) orgIDs := make([]uuid.UUID, 0) - projectsByOrg := make(map[uuid.UUID][]uuid.UUID, 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 := projectsByOrg[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 !slices.Contains(projectsByOrg[wf.OrganizationID], wf.ProjectID) { - projectsByOrg[wf.OrganizationID] = append(projectsByOrg[wf.OrganizationID], wf.ProjectID) - } + 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 diff --git a/app/controlplane/pkg/data/referrer_test.go b/app/controlplane/pkg/data/referrer_test.go index 5f6a0d652..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" @@ -287,3 +289,63 @@ func TestIsReferrerVisible(t *testing.T) { }) } } + +// 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{}) + } +}