From e472c084f667d26ba028c0723b818522ad104e1d 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 | 48 +++-- .../api/controlplane/v1/referrer.proto | 19 ++ .../api/controlplane/v1/referrer_grpc.pb.go | 5 + .../api/controlplane/v1/referrer_http.pb.go | 1 + .../gen/frontend/controlplane/v1/referrer.ts | 52 ++++- ...iscoverPublicSharedRequest.jsonschema.json | 2 +- ...v1.DiscoverPublicSharedRequest.schema.json | 2 +- ...viceDiscoverPrivateRequest.jsonschema.json | 18 ++ ...rServiceDiscoverPrivateRequest.schema.json | 18 ++ app/controlplane/api/gen/openapi/openapi.yaml | 20 ++ app/controlplane/internal/service/referrer.go | 15 +- app/controlplane/pkg/biz/referrer.go | 31 ++- .../pkg/biz/referrer_integration_test.go | 187 ++++++++++++++++++ app/controlplane/pkg/data/referrer.go | 152 +++++++++++++- 15 files changed, 544 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..4f52d7174 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -49,9 +49,15 @@ 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. Can be set on its own + // (project-wide filter) or together with project_version (project + version filter). + 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). + // 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReferrerServiceDiscoverPrivateRequest) Reset() { @@ -105,7 +111,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 +416,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\"\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" + "\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:\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 == '')\"\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 +464,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..aeb6ba0b3 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,20 @@ message ReferrerServiceDiscoverPrivateRequest { string kind = 2; // Pagination options for the references list CursorPaginationRequest pagination = 3; + // ProjectName optionally scopes the discovery to a project by name. Can be set on its own + // (project-wide filter) or together with project_version (project + version filter). + string project_name = 4; + // 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; + + // project_version requires project_name (a version name is unique only within a project); + // project_name on its own is allowed. + option (buf.validate.message).cel = { + id: "discover_project_version_requires_project_name" + expression: "!(this.project_version != '' && this.project_name == '')" + message: "project_name must be set when project_version is set" + }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { @@ -70,7 +86,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..7d3dc8408 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -18,9 +18,24 @@ export interface ReferrerServiceDiscoverPrivateRequest { kind: string; /** Pagination options for the references list */ pagination?: CursorPaginationRequest; + /** + * ProjectName optionally scopes the discovery to a project by name. Can be set on its own + * (project-wide filter) or together with project_version (project + version filter). + */ + projectName: string; + /** + * 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; } -/** 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 +95,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 +109,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 +146,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 +174,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 +185,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 +205,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 +799,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..6e59f03fc 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. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", + "type": "string" + }, + "^(project_version)$": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n Requires project_name, since a version name is unique only within a project.", + "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. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", + "type": "string" + }, + "projectVersion": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n Requires project_name, since a version name is unique only within a project.", + "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..e7901c82f 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. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", + "type": "string" + }, + "^(projectVersion)$": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n Requires project_name, since a version name is unique only within a project.", + "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. Can be set on its own\n (project-wide filter) or together with project_version (project + version filter).", + "type": "string" + }, + "project_version": { + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\n Requires project_name, since a version name is unique only within a project.", + "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..5a3e5a288 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -102,6 +102,26 @@ paths: schema: format: int32 type: integer + - description: >- + ProjectName optionally scopes the discovery to a project by name. + Can be set on its own + + (project-wide filter) or together with project_version (project + + version filter). + 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). + + Requires project_name, since a version name is unique only within a + project. + 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..9275d33c7 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -56,12 +56,20 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return nil, err } + // Optionally scope the discovery by project, optionally narrowed to a project version. + // project_version requires project_name (enforced at the proto validation layer); a + // project_name on its own scopes to every version of that project. + var extraFilters []biz.GetFromRootFilter + if req.GetProjectName() != "" { + 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 { - 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 +84,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 +96,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..48c6022db 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,15 @@ func WithKind(kind string) func(*GetFromRootFilters) { } } +// WithProjectScope scopes the discovery to the given project, optionally narrowed to a +// specific version. Pass an empty projectVersion to scope across all versions of the project. +func WithProjectScope(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 +201,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 +218,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 +232,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 +290,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 +317,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 +359,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..0cbd970be 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,191 @@ 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.WithProjectScope("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.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")) + 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")) + 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.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) + s.NoError(err) + s.Require().NotNil(got) + s.Equal(h.String(), got.Digest) + }) + + s.Run("project_name alone returns the referrer across all versions of that project", func() { + // A second run on workflow1 at v3.0.0 with a fresh attestation digest. With a project-only + // filter, the SBOM must be reachable through either v1.0.0 (via h) or v3.0.0 (via newH). + const newH = "sha256:" + "b" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + runV3, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflow1.ID.String(), ContractRevision: contractVersion, CASBackendID: casBackend.ID, + ProjectVersion: "v3.0.0", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runV3.ID, newH)) + + // 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", "")) + 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", "")) + 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", "")) + s.True(biz.IsNotFound(err)) + s.Nil(got) + }) + + s.Run("RBAC: project filter must respect the caller's visible projects", func() { + // A second workflow in org1 under a different project. Persisting the same envelope on + // it links the existing materials (including the SBOM) to that project too — so the + // material is technically visible to the user via the original "test" project even when + // the second project is not in their visible set. + wfOther, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: "wf-other", Team: "team", OrgID: s.org1.ID, Project: "other-proj", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, h, wfOther.ID.String())) + + // A v1.0.0 run on the second workflow whose attestation digest points at the same + // attestation, so "other-proj" v1.0.0 contains h. + contractOther, err := s.WorkflowContract.Describe(ctx, s.org1.ID, wfOther.ContractID.String(), 0) + require.NoError(s.T(), err) + runOther, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wfOther.ID.String(), ContractRevision: contractOther, CASBackendID: casBackend.ID, + ProjectVersion: "v1.0.0", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runOther.ID, h.String())) + + // RBAC: caller can see project "test" in org1 only — NOT "other-proj". + rbac := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} + + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, rbac, nil, biz.WithProjectScope("other-proj", "v1.0.0")) + s.True(biz.IsNotFound(err), "expected NotFound when filtering by a project the caller cannot see") + s.Nil(got) + + // Sanity: with the visible project, the same material is returned scoped to its version, + // proving the test setup is sound and the fix isn't over-blocking. + got, _, err = s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, rbac, nil, biz.WithProjectScope("test", "v1.0.0")) + s.NoError(err) + s.Require().NotNil(got) + s.Equal(sbomDigest, got.Digest) + }) + + s.Run("public workflow stays visible under the version filter regardless of RBAC", func() { + // A workflow whose project is NOT in the caller's RBAC-visible set, but which is public — + // matching isReferrerVisible's InPublicWorkflow short-circuit. The version filter must + // honor the same convention or it silently hides referrers that are otherwise visible. + wfPublic, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + Name: "wf-public", Team: "team", OrgID: s.org1.ID, Project: "public-proj", + }) + require.NoError(s.T(), err) + _, err = s.Workflow.Update(ctx, s.org1.ID, wfPublic.ID.String(), &biz.WorkflowUpdateOpts{Public: toPtrBool(true)}) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, h, wfPublic.ID.String())) + + contractPublic, err := s.WorkflowContract.Describe(ctx, s.org1.ID, wfPublic.ContractID.String(), 0) + require.NoError(s.T(), err) + runPublic, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: wfPublic.ID.String(), ContractRevision: contractPublic, CASBackendID: casBackend.ID, + ProjectVersion: "v1.0.0", + }) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runPublic.ID, h.String())) + + // RBAC restricts the caller to project "test" — "public-proj" is NOT in their set. + rbac := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} + + got, _, err := s.Referrer.GetFromRoot(ctx, sbomDigest, "", []uuid.UUID{s.org1UUID}, rbac, nil, biz.WithProjectScope("public-proj", "v1.0.0")) + s.NoError(err, "public workflow must remain discoverable even when RBAC excludes its project") + s.Require().NotNil(got) + s.Equal(sbomDigest, 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.WithProjectScope("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..4de7ba371 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" @@ -176,6 +179,24 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] // Attach the workflow predicate predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...)) + // If a project filter is requested, attach it as a subquery predicate. An attestation root + // matches only when its digest is one of the attestation_digests produced by a workflow run + // in the requested project (and, when set, version). Non-attestation roots pass through here + // and are validated later through their references. The cost is independent of how many + // runs the project has — Postgres executes it as a single semi-join, no Go-side digest list. + var projectPred predicate.Referrer + if opts.ProjectName != nil && *opts.ProjectName != "" { + version := "" + if opts.ProjectVersion != nil { + version = *opts.ProjectVersion + } + projectPred = r.projectScopePredicate(*opts.ProjectName, version, orgIDs, opts.ProjectIDs, opts.Public) + predicateReferrer = append(predicateReferrer, referrer.Or( + referrer.KindNEQ(biz.ReferrerAttestationType), + projectPred, + )) + } + refs, err := r.data.DB.Referrer.Query().Where(predicateReferrer...).WithWorkflows().All(ctx) if err != nil { return nil, "", fmt.Errorf("failed to query referrer: %w", err) @@ -194,7 +215,7 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] } // 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, projectPred, p, 0) if err != nil && !biz.IsErrUnauthorized(err) { return nil, "", fmt.Errorf("failed to get referrer: %w", err) } @@ -202,12 +223,99 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] return res, nextCursor, nil } +// projectScopePredicate returns a predicate matching referrers whose digest is the attestation +// digest of a workflow run in the requested project (and, when non-empty, version), visible to +// the caller. The predicate compiles to a SQL subquery — no digest list is materialized in Go, +// so the cost is independent of how many runs the project has. Postgres plans this as a +// semi-join via the index on workflow_run.attestation_digest, which is what makes the filter +// scale at thousands of runs per project. +// +// Visibility mirrors isReferrerVisible: a run is included when its workflow is either public +// (regardless of RBAC) or its project is in the caller's RBAC-visible set. visibleProjectsMap +// follows the existing convention — an org entry present means RBAC applies for that org and +// only the listed project IDs are visible; an org absent means no RBAC restriction. When +// public != nil, the run's workflow visibility is additionally constrained to that value. +// The public-workflow short-circuit is tracked for removal in chainloop-dev/chainloop#3163. +func (r *ReferrerRepo) projectScopePredicate(projectName, version string, orgIDs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID, public *bool) predicate.Referrer { + versionPredicates := []predicate.ProjectVersion{ + projectversion.DeletedAtIsNil(), + projectversion.HasProjectWith( + project.NameEQ(projectName), + project.DeletedAtIsNil(), + ), + } + if version != "" { + versionPredicates = append(versionPredicates, projectversion.VersionEQ(version)) + } + runPredicates := []predicate.WorkflowRun{ + workflowrun.AttestationDigestNEQ(""), + workflowrun.HasVersionWith(versionPredicates...), + } + + // Visibility OR — same semantics as isReferrerVisible: a public workflow is visible to any + // caller in its org, otherwise the project must be in the caller's RBAC-visible set. + visibility := []predicate.WorkflowRun{ + workflowrun.HasWorkflowWith( + workflow.DeletedAtIsNil(), + workflow.Public(true), + workflow.HasOrganizationWith(organization.IDIn(orgIDs...)), + ), + } + if rbacScope := projectVisibilityPredicate(orgIDs, visibleProjectsMap); rbacScope != nil { + visibility = append(visibility, workflowrun.HasWorkflowWith( + workflow.DeletedAtIsNil(), + workflow.HasProjectWith(rbacScope), + )) + } + runPredicates = append(runPredicates, workflowrun.Or(visibility...)) + + // If the caller explicitly scopes by public/private, layer that on top. + if public != nil { + runPredicates = append(runPredicates, workflowrun.HasWorkflowWith(workflow.Public(*public))) + } + + return func(s *sql.Selector) { + t := sql.Table(workflowrun.Table) + sub := sql.Select(t.C(workflowrun.FieldAttestationDigest)).From(t) + for _, p := range runPredicates { + p(sub) + } + s.Where(sql.In(s.C(referrer.FieldDigest), sub)) + } +} + +// projectVisibilityPredicate builds a project predicate that accepts a project iff it belongs to +// one of the allowed orgs AND, when RBAC applies to that org, the project is in the caller's +// visible set. Returns nil when no org grants any project visibility, so callers can fall back +// to other visibility paths (e.g. public workflows). +func projectVisibilityPredicate(orgIDs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) predicate.Project { + perOrg := make([]predicate.Project, 0, len(orgIDs)) + for _, orgID := range orgIDs { + visible, hasRBAC := visibleProjectsMap[orgID] + if !hasRBAC { + perOrg = append(perOrg, project.HasOrganizationWith(organization.ID(orgID))) + continue + } + if len(visible) == 0 { + continue // RBAC applies but no project is visible in this org + } + perOrg = append(perOrg, project.And( + project.HasOrganizationWith(organization.ID(orgID)), + project.IDIn(visible...), + )) + } + if len(perOrg) == 0 { + return nil + } + return project.Or(perOrg...) +} + // 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, projectPred predicate.Referrer, p *pagination.CursorOptions, level int) (*biz.StoredReferrer, string, error) { // Assemble the referrer to return res := &biz.StoredReferrer{ ID: root.ID, @@ -229,6 +337,13 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg return nil, "", biz.NewErrUnauthorizedStr("referrer not allowed") } + // When a project filter is active, an attestation root has already been filtered by the + // initial referrer lookup (the projectPred subquery), so it is guaranteed to belong to the + // requested project here. A material root passes that lookup unconditionally and is + // validated through its references below (or via the pagination-independent existence + // check after the references query). + projectFilterActive := projectPred != nil + // Next: We'll find the references recursively up to a max of maxTraverseLevels levels if level >= maxTraverseLevels { return res, "", nil @@ -250,6 +365,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, attestation references must belong to that project (optionally + // narrowed to a version). Non-attestation references (materials/subjects) are kept as-is: + // they inherit the project through the attestation that references them. + if projectFilterActive { + predicateReferrer = append(predicateReferrer, referrer.Or( + referrer.KindNEQ(biz.ReferrerAttestationType), + projectPred, + )) + } + // 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 +414,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, projectPred, nil, level+1) if err != nil && !biz.IsErrUnauthorized(err) { return nil, "", fmt.Errorf("failed to get referrer: %w", err) } @@ -299,6 +424,27 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg } } + // A non-attestation root (a material/subject) belongs to the requested project only if it + // is referenced by at least one attestation in that project (or in the specific version, + // if one was requested). 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 projectFilterActive && level == 0 && root.Kind != biz.ReferrerAttestationType && len(res.References) == 0 { + inProject, err := root.QueryReferences(). + Where( + referrer.KindEQ(biz.ReferrerAttestationType), + projectPred, + referrer.HasWorkflowsWith(predicateWF...), + ). + Exist(ctx) + if err != nil { + return nil, "", fmt.Errorf("failed to validate project membership: %w", err) + } + if !inProject { + return nil, "", biz.NewErrUnauthorizedStr("referrer not part of the requested project") + } + } + return res, nextCursor, nil }