From 1641767b0ff00a1544a7cd8b4a3339c373bad399 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Wed, 27 May 2026 13:15:46 +0200 Subject: [PATCH] feat(controlplane): filter referrer discovery by project name and version Add optional project_name and project_version filters to the private referrer discovery endpoint (DiscoverPrivate). When both are provided, the discovered referrer and its references are confined to the matching project version, resolved by entering from the project version's workflow runs so the lookup stays bounded regardless of how widely a material is shared. Mark the deprecated public shared discovery endpoint as deprecated in the proto. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 593298f0-05bd-408b-9767-5472afe1caec --- app/cli/pkg/action/referrer_discover.go | 3 + .../api/controlplane/v1/referrer.pb.go | 49 +++++++--- .../api/controlplane/v1/referrer.proto | 20 ++++ .../api/controlplane/v1/referrer_grpc.pb.go | 5 + .../api/controlplane/v1/referrer_http.pb.go | 1 + .../gen/frontend/controlplane/v1/referrer.ts | 53 ++++++++++- ...iscoverPublicSharedRequest.jsonschema.json | 2 +- ...v1.DiscoverPublicSharedRequest.schema.json | 2 +- ...viceDiscoverPrivateRequest.jsonschema.json | 18 ++++ ...rServiceDiscoverPrivateRequest.schema.json | 18 ++++ app/controlplane/api/gen/openapi/openapi.yaml | 19 ++++ app/controlplane/internal/service/referrer.go | 14 ++- app/controlplane/pkg/biz/referrer.go | 30 ++++-- .../pkg/biz/referrer_integration_test.go | 91 +++++++++++++++++++ app/controlplane/pkg/data/referrer.go | 87 +++++++++++++++++- 15 files changed, 383 insertions(+), 29 deletions(-) diff --git a/app/cli/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 3f34652a0..410a74329 100644 --- a/app/cli/pkg/action/referrer_discover.go +++ b/app/cli/pkg/action/referrer_discover.go @@ -67,6 +67,9 @@ func NewReferrerDiscoverPublicIndex(cfg *ActionsOpts) *ReferrerDiscoverPublic { return &ReferrerDiscoverPublic{cfg} } +// Run calls the deprecated public shared index RPC, kept for backwards compatibility. +// +//nolint:staticcheck // the RPC is deprecated but still supported func (action *ReferrerDiscoverPublic) Run(ctx context.Context, digest, kind string, p *PaginationOpts) (*ReferrerDiscoverResult, error) { client := pb.NewReferrerServiceClient(action.cfg.CPConnection) resp, err := client.DiscoverPublicShared(ctx, &pb.DiscoverPublicSharedRequest{ diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index f7fe021ee..d98b09eb7 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -49,9 +49,16 @@ type ReferrerServiceDiscoverPrivateRequest struct { // Used to filter and resolve ambiguities Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` // Pagination options for the references list - Pagination *CursorPaginationRequest `protobuf:"bytes,3,opt,name=pagination,proto3" json:"pagination,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Pagination *CursorPaginationRequest `protobuf:"bytes,3,opt,name=pagination,proto3" json:"pagination,omitempty"` + // ProjectName optionally scopes the discovery to a project by name. + // Must be set together with project_version. + ProjectName string `protobuf:"bytes,4,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` + // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). + // The referrer and its references are confined to this project version. + // Must be set together with project_name. + ProjectVersion string `protobuf:"bytes,5,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReferrerServiceDiscoverPrivateRequest) Reset() { @@ -105,7 +112,24 @@ func (x *ReferrerServiceDiscoverPrivateRequest) GetPagination() *CursorPaginatio return nil } +func (x *ReferrerServiceDiscoverPrivateRequest) GetProjectName() string { + if x != nil { + return x.ProjectName + } + return "" +} + +func (x *ReferrerServiceDiscoverPrivateRequest) GetProjectVersion() string { + if x != nil { + return x.ProjectVersion + } + return "" +} + // DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method +// Deprecated: the public shared index is being retired. +// +// Deprecated: Marked as deprecated in controlplane/v1/referrer.proto. type DiscoverPublicSharedRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Digest is the unique identifier of the referrer to discover @@ -393,21 +417,24 @@ 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\"\xfc\x01\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\"\xe7\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" + "\n" + "pagination\x18\x03 \x01(\v2(.controlplane.v1.CursorPaginationRequestR\n" + - "pagination:T\x92AQ\n" + - "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\"\xee\x01\n" + + "pagination\x12!\n" + + "\fproject_name\x18\x04 \x01(\tR\vprojectName\x12'\n" + + "\x0fproject_version\x18\x05 \x01(\tR\x0eprojectVersion:\xf2\x01\x92AQ\n" + + "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\xbaH\x9a\x01\x1a\x97\x01\n" + + "#discover_project_version_dependency\x125project_name and project_version must be set together\x1a9(this.project_name == '') == (this.project_version == '')\"\xf0\x01\n" + "\x1bDiscoverPublicSharedRequest\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" + "\n" + "pagination\x18\x03 \x01(\v2(.controlplane.v1.CursorPaginationRequestR\n" + - "pagination:P\x92AM\n" + - "K*\x1bDiscoverPublicSharedRequest2,Request to discover a public shared referrer\"\xf3\x01\n" + + "pagination:R\x92AM\n" + + "K*\x1bDiscoverPublicSharedRequest2,Request to discover a public shared referrer\x18\x01\"\xf3\x01\n" + "\x1cDiscoverPublicSharedResponse\x125\n" + "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result\x12I\n" + "\n" + @@ -438,10 +465,10 @@ 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 system2\xa9\x05\n" + + "=*\fReferrerItem2-It represents a referrer object in the system2\xac\x05\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}\x12\x96\x02\n" + - "\x14DiscoverPublicShared\x12,.controlplane.v1.DiscoverPublicSharedRequest\x1a-.controlplane.v1.DiscoverPublicSharedResponse\"\xa0\x01\x92A|\x12\x1fDiscover public shared referrer\x1aGReturns the referrer item for a given digest in the public shared index:\x10application/json\x82\xd3\xe4\x93\x02\x1b\x12\x19/discover/shared/{digest}\x1aQ\x92AN\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}\x12\x99\x02\n" + + "\x14DiscoverPublicShared\x12,.controlplane.v1.DiscoverPublicSharedRequest\x1a-.controlplane.v1.DiscoverPublicSharedResponse\"\xa3\x01\x92A|\x12\x1fDiscover public shared referrer\x1aGReturns the referrer item for a given digest in the public shared index:\x10application/json\x82\xd3\xe4\x93\x02\x1b\x12\x19/discover/shared/{digest}\x88\x02\x01\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 96f1b86df..525dcf3db 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -36,7 +36,9 @@ service ReferrerService { }; } // DiscoverPublicShared returns the referrer item for a given digest in the public shared index + // Deprecated: the public shared index is being retired. rpc DiscoverPublicShared(DiscoverPublicSharedRequest) returns (DiscoverPublicSharedResponse) { + option deprecated = true; option (google.api.http) = {get: "/discover/shared/{digest}"}; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Discover public shared referrer" @@ -60,6 +62,21 @@ message ReferrerServiceDiscoverPrivateRequest { string kind = 2; // Pagination options for the references list CursorPaginationRequest pagination = 3; + // ProjectName optionally scopes the discovery to a project by name. + // Must be set together with project_version. + string project_name = 4; + // ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). + // The referrer and its references are confined to this project version. + // Must be set together with project_name. + string project_version = 5; + + // project_name and project_version must be provided together: a version name is unique only + // within a project, and a project name on its own would not scope the discovery. + option (buf.validate.message).cel = { + id: "discover_project_version_dependency" + expression: "(this.project_name == '') == (this.project_version == '')" + message: "project_name and project_version must be set together" + }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { @@ -70,7 +87,10 @@ message ReferrerServiceDiscoverPrivateRequest { } // DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method +// Deprecated: the public shared index is being retired. message DiscoverPublicSharedRequest { + option deprecated = true; + // Digest is the unique identifier of the referrer to discover string digest = 1 [(buf.validate.field).string = {min_len: 1}]; // Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ... diff --git a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go index b95175c80..3f2303ee9 100644 --- a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go @@ -44,7 +44,9 @@ const ( type ReferrerServiceClient interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) + // Deprecated: Do not use. // DiscoverPublicShared returns the referrer item for a given digest in the public shared index + // Deprecated: the public shared index is being retired. DiscoverPublicShared(ctx context.Context, in *DiscoverPublicSharedRequest, opts ...grpc.CallOption) (*DiscoverPublicSharedResponse, error) } @@ -65,6 +67,7 @@ func (c *referrerServiceClient) DiscoverPrivate(ctx context.Context, in *Referre return out, nil } +// Deprecated: Do not use. func (c *referrerServiceClient) DiscoverPublicShared(ctx context.Context, in *DiscoverPublicSharedRequest, opts ...grpc.CallOption) (*DiscoverPublicSharedResponse, error) { out := new(DiscoverPublicSharedResponse) err := c.cc.Invoke(ctx, ReferrerService_DiscoverPublicShared_FullMethodName, in, out, opts...) @@ -80,7 +83,9 @@ func (c *referrerServiceClient) DiscoverPublicShared(ctx context.Context, in *Di type ReferrerServiceServer interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) + // Deprecated: Do not use. // DiscoverPublicShared returns the referrer item for a given digest in the public shared index + // Deprecated: the public shared index is being retired. DiscoverPublicShared(context.Context, *DiscoverPublicSharedRequest) (*DiscoverPublicSharedResponse, 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 bf0b017da..14d169f4c 100644 --- a/app/controlplane/api/controlplane/v1/referrer_http.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_http.pb.go @@ -26,6 +26,7 @@ type ReferrerServiceHTTPServer interface { // DiscoverPrivate DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) // DiscoverPublicShared DiscoverPublicShared returns the referrer item for a given digest in the public shared index + // Deprecated: the public shared index is being retired. DiscoverPublicShared(context.Context, *DiscoverPublicSharedRequest) (*DiscoverPublicSharedResponse, error) } diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index b63be3b7a..fc1f05161 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -18,9 +18,25 @@ export interface ReferrerServiceDiscoverPrivateRequest { kind: string; /** Pagination options for the references list */ pagination?: CursorPaginationRequest; + /** + * ProjectName optionally scopes the discovery to a project by name. + * Must be set together with project_version. + */ + projectName: string; + /** + * ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0). + * The referrer and its references are confined to this project version. + * Must be set together with project_name. + */ + projectVersion: string; } -/** DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method */ +/** + * DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method + * Deprecated: the public shared index is being retired. + * + * @deprecated + */ export interface DiscoverPublicSharedRequest { /** Digest is the unique identifier of the referrer to discover */ digest: string; @@ -80,7 +96,7 @@ export interface ReferrerItem_AnnotationsEntry { } function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { - return { digest: "", kind: "", pagination: undefined }; + return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "" }; } export const ReferrerServiceDiscoverPrivateRequest = { @@ -94,6 +110,12 @@ export const ReferrerServiceDiscoverPrivateRequest = { if (message.pagination !== undefined) { CursorPaginationRequest.encode(message.pagination, writer.uint32(26).fork()).ldelim(); } + if (message.projectName !== "") { + writer.uint32(34).string(message.projectName); + } + if (message.projectVersion !== "") { + writer.uint32(42).string(message.projectVersion); + } return writer; }, @@ -125,6 +147,20 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.pagination = CursorPaginationRequest.decode(reader, reader.uint32()); continue; + case 4: + if (tag !== 34) { + break; + } + + message.projectName = reader.string(); + continue; + case 5: + if (tag !== 42) { + break; + } + + message.projectVersion = reader.string(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -139,6 +175,8 @@ export const ReferrerServiceDiscoverPrivateRequest = { digest: isSet(object.digest) ? String(object.digest) : "", kind: isSet(object.kind) ? String(object.kind) : "", pagination: isSet(object.pagination) ? CursorPaginationRequest.fromJSON(object.pagination) : undefined, + projectName: isSet(object.projectName) ? String(object.projectName) : "", + projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", }; }, @@ -148,6 +186,8 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.kind !== undefined && (obj.kind = message.kind); message.pagination !== undefined && (obj.pagination = message.pagination ? CursorPaginationRequest.toJSON(message.pagination) : undefined); + message.projectName !== undefined && (obj.projectName = message.projectName); + message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); return obj; }, @@ -166,6 +206,8 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.pagination = (object.pagination !== undefined && object.pagination !== null) ? CursorPaginationRequest.fromPartial(object.pagination) : undefined; + message.projectName = object.projectName ?? ""; + message.projectVersion = object.projectVersion ?? ""; return message; }, }; @@ -758,7 +800,12 @@ export interface ReferrerService { request: DeepPartial, metadata?: grpc.Metadata, ): Promise; - /** DiscoverPublicShared returns the referrer item for a given digest in the public shared index */ + /** + * DiscoverPublicShared returns the referrer item for a given digest in the public shared index + * Deprecated: the public shared index is being retired. + * + * @deprecated + */ DiscoverPublicShared( request: DeepPartial, metadata?: grpc.Metadata, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json index 08efa148f..c1e135f13 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json @@ -2,7 +2,7 @@ "$id": "controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method", + "description": "DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method\n Deprecated: the public shared index is being retired.", "properties": { "digest": { "description": "Digest is the unique identifier of the referrer to discover", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json index 0a175476f..498bb60b5 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json @@ -2,7 +2,7 @@ "$id": "controlplane.v1.DiscoverPublicSharedRequest.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method", + "description": "DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method\n Deprecated: the public shared index is being retired.", "properties": { "digest": { "description": "Digest is the unique identifier of the referrer to discover", 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 535b0abfb..b9e2ed810 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json @@ -3,6 +3,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", + "patternProperties": { + "^(project_name)$": { + "description": "ProjectName optionally scopes the discovery to a project by name.\n Must be set together with project_version.", + "type": "string" + }, + "^(project_version)$": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n The referrer and its references are confined to this project version.\n Must be set together with project_name.", + "type": "string" + } + }, "properties": { "digest": { "description": "Digest is the unique identifier of the referrer to discover", @@ -16,6 +26,14 @@ "pagination": { "$ref": "controlplane.v1.CursorPaginationRequest.jsonschema.json", "description": "Pagination options for the references list" + }, + "projectName": { + "description": "ProjectName optionally scopes the discovery to a project by name.\n Must be set together with project_version.", + "type": "string" + }, + "projectVersion": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n The referrer and its references are confined to this project version.\n Must be set together with project_name.", + "type": "string" } }, "title": "Referrer Service Discover Private Request", 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 339c19728..e252aa99c 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json @@ -3,6 +3,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method", + "patternProperties": { + "^(projectName)$": { + "description": "ProjectName optionally scopes the discovery to a project by name.\n Must be set together with project_version.", + "type": "string" + }, + "^(projectVersion)$": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n The referrer and its references are confined to this project version.\n Must be set together with project_name.", + "type": "string" + } + }, "properties": { "digest": { "description": "Digest is the unique identifier of the referrer to discover", @@ -16,6 +26,14 @@ "pagination": { "$ref": "controlplane.v1.CursorPaginationRequest.schema.json", "description": "Pagination options for the references list" + }, + "project_name": { + "description": "ProjectName optionally scopes the discovery to a project by name.\n Must be set together with project_version.", + "type": "string" + }, + "project_version": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n The referrer and its references are confined to this project version.\n Must be set together with project_name.", + "type": "string" } }, "title": "Referrer Service Discover Private Request", diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index c781887c2..48b94286e 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -102,6 +102,25 @@ paths: schema: format: int32 type: integer + - description: |- + ProjectName optionally scopes the discovery to a project by name. + Must be set together with project_version. + in: query + name: project_name + schema: + type: string + - description: >- + ProjectVersion optionally scopes the discovery to a project version + (by name, e.g. v1.2.0). + + The referrer and its references are confined to this project + version. + + Must be set together with project_name. + in: query + name: project_version + schema: + type: string responses: '200': content: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 0d5cfd61b..f783adb93 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -56,12 +56,19 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return nil, err } + // Optionally scope the discovery to a project version. Both fields are required together, + // which is enforced at the proto validation layer. + var extraFilters []biz.GetFromRootFilter + if req.GetProjectVersion() != "" { + extraFilters = append(extraFilters, biz.WithProjectVersion(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 { - referrer, nextCursor, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID, paginationOpts) + 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) @@ -76,7 +83,7 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS orgsProjectsMap[orgUUID] = visibleProjects } - referrer, nextCursor, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, orgsProjectsMap, paginationOpts) + referrer, nextCursor, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, orgsProjectsMap, paginationOpts, extraFilters...) } if err != nil { return nil, handleUseCaseErr(err, s.log) @@ -88,6 +95,9 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS }, nil } +// DiscoverPublicShared implements the deprecated public shared index RPC, kept for backwards compatibility. +// +//nolint:staticcheck // the RPC is deprecated but still served func (s *ReferrerService) DiscoverPublicShared(ctx context.Context, req *pb.DiscoverPublicSharedRequest) (*pb.DiscoverPublicSharedResponse, error) { paginationOpts, err := referrerPaginationOptsFromProto(req.GetPagination()) if err != nil { diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 48753e44e..c3f179240 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -132,6 +132,10 @@ type GetFromRootFilters struct { // ProjectIDs stores visible projects by org for the requesting user. // If an org entry doesn't exist, it means that RBAC is not applied, hence all projects in that org are visible ProjectIDs map[OrgID][]ProjectID + // ProjectName and ProjectVersion scope the discovery to a specific project version. + // Both must be set together (a version name is unique only within a project). + ProjectName *string + ProjectVersion *string } type GetFromRootFilter func(*GetFromRootFilters) @@ -142,6 +146,14 @@ func WithKind(kind string) func(*GetFromRootFilters) { } } +// WithProjectVersion scopes the discovery to the given project name and version. +func WithProjectVersion(projectName, projectVersion string) func(*GetFromRootFilters) { + return func(o *GetFromRootFilters) { + o.ProjectName = &projectName + o.ProjectVersion = &projectVersion + } +} + // 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) { @@ -188,7 +200,7 @@ func (s *ReferrerUseCase) ExtractAndPersist(ctx context.Context, att *dsse.Envel // GetFromRootUser returns the referrer identified by the provided content digest, including its first-level references. // For example if sha:deadbeef represents an attestation, the result will contain the attestation + materials associated to it. // It only returns referrers that belong to organizations the user is member of. -func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, userID string, p *pagination.CursorOptions) (*StoredReferrer, string, error) { +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() @@ -205,10 +217,10 @@ func (s *ReferrerUseCase) GetFromRootUser(ctx context.Context, digest, rootKind, // We pass the list of organizationsIDs from where to look for the referrer // For now we just pass the list of organizations the user is member of // in the future we will expand this to publicly available orgs and so on. - return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs, p) + return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs, p, extraFilters...) } -func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, p *pagination.CursorOptions) (*StoredReferrer, string, error) { +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() @@ -219,6 +231,7 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri if projectIDs != nil { filters = append(filters, WithVisibleProjectIDs(projectIDs)) } + filters = append(filters, extraFilters...) ref, nextCursor, err := s.repo.GetFromRoot(ctx, digest, orgIDs, p, filters...) if err != nil { @@ -276,7 +289,8 @@ func (s *ReferrerUseCase) GetFromRootInPublicSharedIndex(ctx context.Context, di } const ( - referrerAttestationType = "ATTESTATION" + // ReferrerAttestationType is the kind of the referrer that represents an attestation. + ReferrerAttestationType = "ATTESTATION" referrerGitHeadType = "GIT_HEAD_COMMIT" ) @@ -302,11 +316,11 @@ func extractReferrers(att *dsse.Envelope, digest cr_v1.Hash, repo ReferrerRepo) attestationHash := digest.String() attestationReferrer := &Referrer{ Digest: attestationHash, - Kind: referrerAttestationType, + Kind: ReferrerAttestationType, Downloadable: true, } - referrersMap[newRef(attestationHash, referrerAttestationType)] = attestationReferrer + referrersMap[newRef(attestationHash, ReferrerAttestationType)] = attestationReferrer // 2 - Predicate that's referenced from the attestation predicate, err := chainloop.ExtractPredicate(att) @@ -344,8 +358,8 @@ func extractReferrers(att *dsse.Envelope, digest cr_v1.Hash, repo ReferrerRepo) // If we are inserting an attestation as a dependent, we want to make sure it already exists // stored in the system. This is so we can ensure that the attestations nodes are created through // an attestation process, not as a referenced provided by the user - if material.Type == referrerAttestationType { - if exists, err := repo.Exist(context.Background(), material.Hash.String(), WithKind(referrerAttestationType)); err != nil { + if material.Type == ReferrerAttestationType { + if exists, err := repo.Exist(context.Background(), material.Hash.String(), WithKind(ReferrerAttestationType)); err != nil { return nil, fmt.Errorf("checking if attestation exists: %w", err) } else if !exists { return nil, fmt.Errorf("attestation material does not exist %q", material.Hash.String()) diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 3543fd568..7b8081f25 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -20,8 +20,10 @@ import ( "context" "encoding/json" "os" + "strings" "sync" "testing" + "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" @@ -531,6 +533,95 @@ func (s *referrerIntegrationTestSuite) TestPagination() { }) } +func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { + // Load attestation and persist its referrers under workflow1 (project "test") + envelope, envBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") + h, _, err := v1.SHA256(bytes.NewReader(envBytes)) + require.NoError(s.T(), err) + ctx := context.Background() + + err = s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) + require.NoError(s.T(), err) + + // The SBOM is one of the materials referenced by the attestation + const sbomDigest = "sha256:16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c" + + // Create a workflow run for project version "v1.0.0" on workflow1 and link the attestation digest to it + contractVersion, err := s.WorkflowContract.Describe(ctx, s.org1.ID, s.workflow1.ContractID.String(), 0) + require.NoError(s.T(), err) + casBackend, err := s.CASBackend.CreateOrUpdate(ctx, s.org1.ID, "repo", "username", "pass", backendType, true) + require.NoError(s.T(), err) + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflow1.ID.String(), ContractRevision: contractVersion, CASBackendID: casBackend.ID, + ProjectVersion: "v1.0.0", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run.ID, h.String())) + + s.Run("attestation root is returned when project+version match", func() { + got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectVersion("test", "v1.0.0")) + s.NoError(err) + s.Require().NotNil(got) + s.Equal(h.String(), got.Digest) + // children (materials) are still returned + s.NotEmpty(got.References) + }) + + 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.WithProjectVersion("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.WithProjectVersion("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.WithProjectVersion("test", "v1.0.0")) + s.NoError(err) + s.Require().NotNil(got) + s.Equal(sbomDigest, got.Digest) + // its parent attestation (in version) is returned as a reference + require.Len(s.T(), got.References, 1) + s.Equal(h.String(), got.References[0].Digest) + }) + + s.Run("material root is not found for a different version", func() { + got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectVersion("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) + s.NoError(err) + s.Require().NotNil(got) + s.Equal(h.String(), got.Digest) + }) + + s.Run("material root cannot bypass version scoping by supplying a cursor", func() { + // A second project version whose run points to an unrelated attestation digest, so the + // SBOM (only referenced by the v1.0.0 attestation) does not belong to it. + run2, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflow1.ID.String(), ContractRevision: contractVersion, CASBackendID: casBackend.ID, + ProjectVersion: "v2.0.0", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run2.ID, "sha256:"+strings.Repeat("a", 64))) + + // Paging past the first page must not skip the membership check (regression for the + // 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.WithProjectVersion("test", "v2.0.0")) + s.True(biz.IsNotFound(err)) + s.Nil(got) + }) +} + type referrerIntegrationTestSuite struct { testhelpers.UseCasesEachTestSuite org1, org2 *biz.Organization diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index b16a78723..5e4fc556f 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -25,8 +25,11 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/projectversion" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/referrer" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflow" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/workflowrun" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/go-kratos/kratos/v2/log" @@ -193,8 +196,23 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] return nil, "", biz.NewErrReferrerAmbiguous(digest, kinds) } + // If a project version filter is requested, resolve the bounded set of attestation + // digests that belong to that project version. We enter from the version side (its runs) + // so the lookup stays bounded by the size of the version, never by the referrer graph. + var versionAttDigests []string + if opts.ProjectName != nil && opts.ProjectVersion != nil { + versionAttDigests, err = r.attestationDigestsInProjectVersion(ctx, *opts.ProjectName, *opts.ProjectVersion, orgIDs) + if err != nil { + return nil, "", fmt.Errorf("failed to resolve project version: %w", err) + } + // The version has no attestations (unknown project/version or empty), so nothing matches + if len(versionAttDigests) == 0 { + return nil, "", nil + } + } + // Find the referrer recursively starting from the root - res, nextCursor, err := r.doGet(ctx, refs[0], orgIDs, opts.ProjectIDs, opts.Public, p, 0) + res, nextCursor, err := r.doGet(ctx, refs[0], orgIDs, opts.ProjectIDs, opts.Public, versionAttDigests, p, 0) if err != nil && !biz.IsErrUnauthorized(err) { return nil, "", fmt.Errorf("failed to get referrer: %w", err) } @@ -202,12 +220,34 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] return res, nextCursor, nil } +// attestationDigestsInProjectVersion returns the attestation digests of the workflow runs that +// belong to the given project name + version, scoped to the allowed organizations. The query +// enters from the project version (a bounded set of runs), so it does not depend on how many +// referrers reference a given material. +func (r *ReferrerRepo) attestationDigestsInProjectVersion(ctx context.Context, projectName, version string, orgIDs []uuid.UUID) ([]string, error) { + return r.data.DB.WorkflowRun.Query(). + Where( + workflowrun.AttestationDigestNEQ(""), + workflowrun.HasVersionWith( + projectversion.VersionEQ(version), + projectversion.DeletedAtIsNil(), + projectversion.HasProjectWith( + project.NameEQ(projectName), + project.DeletedAtIsNil(), + project.HasOrganizationWith(organization.IDIn(orgIDs...)), + ), + ), + ). + Select(workflowrun.FieldAttestationDigest). + Strings(ctx) +} + // max number of recursive levels to traverse // we just care about 1 level, i.e att -> commit, or commit -> attestation // we also need to limit this because there might be cycles const maxTraverseLevels = 1 -func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID, public *bool, p *pagination.CursorOptions, level int) (*biz.StoredReferrer, string, error) { +func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID, public *bool, versionAttDigests []string, p *pagination.CursorOptions, level int) (*biz.StoredReferrer, string, error) { // Assemble the referrer to return res := &biz.StoredReferrer{ ID: root.ID, @@ -229,6 +269,16 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg return nil, "", biz.NewErrUnauthorizedStr("referrer not allowed") } + // When a project version filter is active, the requested root referrer must belong to that + // version. An attestation belongs to the version iff its digest is one of the version's + // attestation digests. A material belongs to the version iff it is referenced by one of those + // attestations, which is enforced below through the references query (an empty result means + // the material is not part of the version). + versionFilterActive := versionAttDigests != nil + if versionFilterActive && level == 0 && root.Kind == biz.ReferrerAttestationType && !slices.Contains(versionAttDigests, root.Digest) { + return nil, "", biz.NewErrUnauthorizedStr("referrer not part of the requested project version") + } + // Next: We'll find the references recursively up to a max of maxTraverseLevels levels if level >= maxTraverseLevels { return res, "", nil @@ -250,6 +300,16 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // Attach the workflow predicate predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...)) + // When scoping to a project version, attestation references must belong to that version. + // Non-attestation references (materials/subjects) are kept as-is: they inherit the version + // through the attestation that references them. + if versionFilterActive { + predicateReferrer = append(predicateReferrer, referrer.Or( + referrer.KindNEQ(biz.ReferrerAttestationType), + referrer.DigestIn(versionAttDigests...), + )) + } + // Defense-in-depth: if the caller did not supply pagination options, fall back // to the package-wide default instead of emitting an unbounded query. This // guarantees the response is bounded even when a future biz-layer caller @@ -289,7 +349,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // Add the references to the result for _, reference := range refs { // Call recursively the function — pagination only applies to the first level - ref, _, err := r.doGet(ctx, reference, allowedOrgs, visibleProjectsMap, public, nil, level+1) + ref, _, err := r.doGet(ctx, reference, allowedOrgs, visibleProjectsMap, public, versionAttDigests, nil, level+1) if err != nil && !biz.IsErrUnauthorized(err) { return nil, "", fmt.Errorf("failed to get referrer: %w", err) } @@ -299,6 +359,27 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg } } + // A non-attestation root (a material/subject) belongs to the requested project version only + // if it is referenced by at least one attestation in that version. When the current page + // yields no references we cannot conclude absence from the page alone (a later page can be + // empty simply because we paged past the results), so we run a pagination-independent + // existence check before rejecting the root. + if versionFilterActive && level == 0 && root.Kind != biz.ReferrerAttestationType && len(res.References) == 0 { + inVersion, err := root.QueryReferences(). + Where( + referrer.KindEQ(biz.ReferrerAttestationType), + referrer.DigestIn(versionAttDigests...), + referrer.HasWorkflowsWith(predicateWF...), + ). + Exist(ctx) + if err != nil { + return nil, "", fmt.Errorf("failed to validate project version membership: %w", err) + } + if !inVersion { + return nil, "", biz.NewErrUnauthorizedStr("referrer not part of the requested project version") + } + } + return res, nextCursor, nil }