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 }