diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index 768169ed2..2e55ea9a0 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package cmd import ( "context" + "github.com/chainloop-dev/chainloop/app/cli/cmd/options" "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" "github.com/spf13/cobra" @@ -26,18 +27,24 @@ import ( func newReferrerDiscoverCmd() *cobra.Command { var digest, kind string var fromPublicIndex bool + paginationOpts := &options.PaginationOpts{DefaultLimit: 20} cmd := &cobra.Command{ Use: "discover", Short: "(Preview) inspect pieces of evidence or artifacts stored through Chainloop", RunE: func(cmd *cobra.Command, args []string) error { - var res *action.ReferrerItem + pagination := &action.PaginationOpts{ + Limit: paginationOpts.Limit, + NextCursor: paginationOpts.NextCursor, + } + + var res *action.ReferrerDiscoverResult var err error if fromPublicIndex { - res, err = action.NewReferrerDiscoverPublicIndex(ActionOpts).Run(context.Background(), digest, kind) + res, err = action.NewReferrerDiscoverPublicIndex(ActionOpts).Run(context.Background(), digest, kind, pagination) } else { - res, err = action.NewReferrerDiscoverPrivate(ActionOpts).Run(context.Background(), digest, kind) + res, err = action.NewReferrerDiscoverPrivate(ActionOpts).Run(context.Background(), digest, kind, pagination) } if err != nil { @@ -55,6 +62,7 @@ func newReferrerDiscoverCmd() *cobra.Command { cmd.Flags().StringVarP(&kind, "kind", "k", "", "optional kind of the referrer, used to disambiguate between multiple referrers with the same digest") cobra.CheckErr(err) cmd.Flags().BoolVar(&fromPublicIndex, "public", false, "discover from public shared index instead of your organizations'") + paginationOpts.AddFlags(cmd) return cmd } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 83ac17fcd..1262ba788 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -1518,6 +1518,8 @@ Options -d, --digest string hash of the attestation, piece of evidence or artifact, i.e sha256:deadbeef -h, --help help for discover -k, --kind string optional kind of the referrer, used to disambiguate between multiple referrers with the same digest +--limit int number of items to show (default 20) +--next string cursor to load the next page --public discover from public shared index instead of your organizations' ``` diff --git a/app/cli/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 5dc4d49e8..3f34652a0 100644 --- a/app/cli/pkg/action/referrer_discover.go +++ b/app/cli/pkg/action/referrer_discover.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -40,36 +40,62 @@ type ReferrerItem struct { Annotations map[string]string `json:"annotations,omitempty"` } +type ReferrerDiscoverResult struct { + Item *ReferrerItem `json:"result"` + NextCursor string `json:"nextCursor,omitempty"` +} + func NewReferrerDiscoverPrivate(cfg *ActionsOpts) *ReferrerDiscover { return &ReferrerDiscover{cfg} } -func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string) (*ReferrerItem, error) { +func (action *ReferrerDiscover) Run(ctx context.Context, digest, kind string, p *PaginationOpts) (*ReferrerDiscoverResult, error) { client := pb.NewReferrerServiceClient(action.cfg.CPConnection) resp, err := client.DiscoverPrivate(ctx, &pb.ReferrerServiceDiscoverPrivateRequest{ - Digest: digest, Kind: kind, + Digest: digest, + Kind: kind, + Pagination: paginationOptsToPb(p), }) if err != nil { return nil, err } - return pbReferrerItemToAction(resp.Result), nil + return newReferrerDiscoverResult(resp.Result, resp.GetPagination()), nil } func NewReferrerDiscoverPublicIndex(cfg *ActionsOpts) *ReferrerDiscoverPublic { return &ReferrerDiscoverPublic{cfg} } -func (action *ReferrerDiscoverPublic) Run(ctx context.Context, digest, kind string) (*ReferrerItem, error) { +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{ - Digest: digest, Kind: kind, + Digest: digest, + Kind: kind, + Pagination: paginationOptsToPb(p), }) if err != nil { return nil, err } - return pbReferrerItemToAction(resp.Result), nil + return newReferrerDiscoverResult(resp.Result, resp.GetPagination()), nil +} + +func paginationOptsToPb(p *PaginationOpts) *pb.CursorPaginationRequest { + if p == nil { + return nil + } + return &pb.CursorPaginationRequest{ + Limit: int32(p.Limit), + Cursor: p.NextCursor, + } +} + +func newReferrerDiscoverResult(item *pb.ReferrerItem, p *pb.CursorPaginationResponse) *ReferrerDiscoverResult { + return &ReferrerDiscoverResult{ + Item: pbReferrerItemToAction(item), + NextCursor: p.GetNextCursor(), + } } func pbReferrerItemToAction(in *pb.ReferrerItem) *ReferrerItem { diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 25df8d2ba..f7fe021ee 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -47,7 +47,9 @@ type ReferrerServiceDiscoverPrivateRequest struct { Digest string `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` // Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ... // Used to filter and resolve ambiguities - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + 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 } @@ -96,6 +98,13 @@ func (x *ReferrerServiceDiscoverPrivateRequest) GetKind() string { return "" } +func (x *ReferrerServiceDiscoverPrivateRequest) GetPagination() *CursorPaginationRequest { + if x != nil { + return x.Pagination + } + return nil +} + // DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method type DiscoverPublicSharedRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -103,7 +112,9 @@ type DiscoverPublicSharedRequest struct { Digest string `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` // Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ... // Used to filter and resolve ambiguities - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + 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 } @@ -152,11 +163,20 @@ func (x *DiscoverPublicSharedRequest) GetKind() string { return "" } +func (x *DiscoverPublicSharedRequest) GetPagination() *CursorPaginationRequest { + if x != nil { + return x.Pagination + } + return nil +} + // DiscoverPublicSharedResponse is the response for the DiscoverPublicShared method type DiscoverPublicSharedResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Result is the discovered referrer item - Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + // Pagination information for the references list + Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -198,11 +218,20 @@ func (x *DiscoverPublicSharedResponse) GetResult() *ReferrerItem { return nil } +func (x *DiscoverPublicSharedResponse) GetPagination() *CursorPaginationResponse { + if x != nil { + return x.Pagination + } + return nil +} + // ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method type ReferrerServiceDiscoverPrivateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Result is the discovered referrer item - Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + Result *ReferrerItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + // Pagination information for the references list + Pagination *CursorPaginationResponse `protobuf:"bytes,2,opt,name=pagination,proto3" json:"pagination,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -244,6 +273,13 @@ func (x *ReferrerServiceDiscoverPrivateResponse) GetResult() *ReferrerItem { return nil } +func (x *ReferrerServiceDiscoverPrivateResponse) GetPagination() *CursorPaginationResponse { + if x != nil { + return x.Pagination + } + return nil +} + // ReferrerItem represents a referrer object in the system type ReferrerItem struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -357,20 +393,32 @@ 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\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xb2\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\"\xfc\x01\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:T\x92AQ\n" + - "O*%ReferrerServiceDiscoverPrivateRequest2&Request to discover a private referrer\"\xa4\x01\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" + "\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:P\x92AM\n" + - "K*\x1bDiscoverPublicSharedRequest2,Request to discover a public shared referrer\"\xa8\x01\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" + "\x1cDiscoverPublicSharedResponse\x125\n" + - "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result:Q\x92AN\n" + - "L*\x1cDiscoverPublicSharedResponse2,Response for the DiscoverPublicShared method\"\xb7\x01\n" + + "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result\x12I\n" + + "\n" + + "pagination\x18\x02 \x01(\v2).controlplane.v1.CursorPaginationResponseR\n" + + "pagination:Q\x92AN\n" + + "L*\x1cDiscoverPublicSharedResponse2,Response for the DiscoverPublicShared method\"\x82\x02\n" + "&ReferrerServiceDiscoverPrivateResponse\x125\n" + - "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result:V\x92AS\n" + + "\x06result\x18\x01 \x01(\v2\x1d.controlplane.v1.ReferrerItemR\x06result\x12I\n" + + "\n" + + "pagination\x18\x02 \x01(\v2).controlplane.v1.CursorPaginationResponseR\n" + + "pagination:V\x92AS\n" + "Q*&ReferrerServiceDiscoverPrivateResponse2'Response for the DiscoverPrivate method\"\xcc\x04\n" + "\fReferrerItem\x12\x16\n" + "\x06digest\x18\x01 \x01(\tR\x06digest\x12\x12\n" + @@ -417,24 +465,30 @@ var file_controlplane_v1_referrer_proto_goTypes = []any{ (*ReferrerItem)(nil), // 4: controlplane.v1.ReferrerItem nil, // 5: controlplane.v1.ReferrerItem.MetadataEntry nil, // 6: controlplane.v1.ReferrerItem.AnnotationsEntry - (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp + (*CursorPaginationRequest)(nil), // 7: controlplane.v1.CursorPaginationRequest + (*CursorPaginationResponse)(nil), // 8: controlplane.v1.CursorPaginationResponse + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp } var file_controlplane_v1_referrer_proto_depIdxs = []int32{ - 4, // 0: controlplane.v1.DiscoverPublicSharedResponse.result:type_name -> controlplane.v1.ReferrerItem - 4, // 1: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem - 4, // 2: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem - 7, // 3: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp - 5, // 4: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry - 6, // 5: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry - 0, // 6: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest - 1, // 7: controlplane.v1.ReferrerService.DiscoverPublicShared:input_type -> controlplane.v1.DiscoverPublicSharedRequest - 3, // 8: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse - 2, // 9: controlplane.v1.ReferrerService.DiscoverPublicShared:output_type -> controlplane.v1.DiscoverPublicSharedResponse - 8, // [8:10] is the sub-list for method output_type - 6, // [6:8] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 7, // 0: controlplane.v1.ReferrerServiceDiscoverPrivateRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest + 7, // 1: controlplane.v1.DiscoverPublicSharedRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest + 4, // 2: controlplane.v1.DiscoverPublicSharedResponse.result:type_name -> controlplane.v1.ReferrerItem + 8, // 3: controlplane.v1.DiscoverPublicSharedResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse + 4, // 4: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem + 8, // 5: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse + 4, // 6: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem + 9, // 7: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp + 5, // 8: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry + 6, // 9: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry + 0, // 10: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest + 1, // 11: controlplane.v1.ReferrerService.DiscoverPublicShared:input_type -> controlplane.v1.DiscoverPublicSharedRequest + 3, // 12: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse + 2, // 13: controlplane.v1.ReferrerService.DiscoverPublicShared:output_type -> controlplane.v1.DiscoverPublicSharedResponse + 12, // [12:14] is the sub-list for method output_type + 10, // [10:12] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_controlplane_v1_referrer_proto_init() } @@ -442,6 +496,7 @@ func file_controlplane_v1_referrer_proto_init() { if File_controlplane_v1_referrer_proto != nil { return } + file_controlplane_v1_pagination_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index c93bbe6f4..96f1b86df 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ syntax = "proto3"; package controlplane.v1; import "buf/validate/validate.proto"; +import "controlplane/v1/pagination.proto"; import "google/api/annotations.proto"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -57,6 +58,8 @@ message ReferrerServiceDiscoverPrivateRequest { // Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ... // Used to filter and resolve ambiguities string kind = 2; + // Pagination options for the references list + CursorPaginationRequest pagination = 3; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { @@ -73,6 +76,8 @@ message DiscoverPublicSharedRequest { // Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ... // Used to filter and resolve ambiguities string kind = 2; + // Pagination options for the references list + CursorPaginationRequest pagination = 3; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { @@ -86,6 +91,8 @@ message DiscoverPublicSharedRequest { message DiscoverPublicSharedResponse { // Result is the discovered referrer item ReferrerItem result = 1; + // Pagination information for the references list + CursorPaginationResponse pagination = 2; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { @@ -99,6 +106,8 @@ message DiscoverPublicSharedResponse { message ReferrerServiceDiscoverPrivateResponse { // Result is the discovered referrer item ReferrerItem result = 1; + // Pagination information for the references list + CursorPaginationResponse pagination = 2; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { diff --git a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go index e3aed4bc2..b95175c80 100644 --- a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 39b6e2892..b63be3b7a 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -3,6 +3,7 @@ import { grpc } from "@improbable-eng/grpc-web"; import { BrowserHeaders } from "browser-headers"; import _m0 from "protobufjs/minimal"; import { Timestamp } from "../../google/protobuf/timestamp"; +import { CursorPaginationRequest, CursorPaginationResponse } from "./pagination"; export const protobufPackage = "controlplane.v1"; @@ -15,6 +16,8 @@ export interface ReferrerServiceDiscoverPrivateRequest { * Used to filter and resolve ambiguities */ kind: string; + /** Pagination options for the references list */ + pagination?: CursorPaginationRequest; } /** DiscoverPublicSharedRequest is the request for the DiscoverPublicShared method */ @@ -26,18 +29,24 @@ export interface DiscoverPublicSharedRequest { * Used to filter and resolve ambiguities */ kind: string; + /** Pagination options for the references list */ + pagination?: CursorPaginationRequest; } /** DiscoverPublicSharedResponse is the response for the DiscoverPublicShared method */ export interface DiscoverPublicSharedResponse { /** Result is the discovered referrer item */ result?: ReferrerItem; + /** Pagination information for the references list */ + pagination?: CursorPaginationResponse; } /** ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method */ export interface ReferrerServiceDiscoverPrivateResponse { /** Result is the discovered referrer item */ result?: ReferrerItem; + /** Pagination information for the references list */ + pagination?: CursorPaginationResponse; } /** ReferrerItem represents a referrer object in the system */ @@ -71,7 +80,7 @@ export interface ReferrerItem_AnnotationsEntry { } function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { - return { digest: "", kind: "" }; + return { digest: "", kind: "", pagination: undefined }; } export const ReferrerServiceDiscoverPrivateRequest = { @@ -82,6 +91,9 @@ export const ReferrerServiceDiscoverPrivateRequest = { if (message.kind !== "") { writer.uint32(18).string(message.kind); } + if (message.pagination !== undefined) { + CursorPaginationRequest.encode(message.pagination, writer.uint32(26).fork()).ldelim(); + } return writer; }, @@ -106,6 +118,13 @@ export const ReferrerServiceDiscoverPrivateRequest = { message.kind = reader.string(); continue; + case 3: + if (tag !== 26) { + break; + } + + message.pagination = CursorPaginationRequest.decode(reader, reader.uint32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -119,6 +138,7 @@ export const ReferrerServiceDiscoverPrivateRequest = { return { digest: isSet(object.digest) ? String(object.digest) : "", kind: isSet(object.kind) ? String(object.kind) : "", + pagination: isSet(object.pagination) ? CursorPaginationRequest.fromJSON(object.pagination) : undefined, }; }, @@ -126,6 +146,8 @@ export const ReferrerServiceDiscoverPrivateRequest = { const obj: any = {}; message.digest !== undefined && (obj.digest = message.digest); message.kind !== undefined && (obj.kind = message.kind); + message.pagination !== undefined && + (obj.pagination = message.pagination ? CursorPaginationRequest.toJSON(message.pagination) : undefined); return obj; }, @@ -141,12 +163,15 @@ export const ReferrerServiceDiscoverPrivateRequest = { const message = createBaseReferrerServiceDiscoverPrivateRequest(); message.digest = object.digest ?? ""; message.kind = object.kind ?? ""; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? CursorPaginationRequest.fromPartial(object.pagination) + : undefined; return message; }, }; function createBaseDiscoverPublicSharedRequest(): DiscoverPublicSharedRequest { - return { digest: "", kind: "" }; + return { digest: "", kind: "", pagination: undefined }; } export const DiscoverPublicSharedRequest = { @@ -157,6 +182,9 @@ export const DiscoverPublicSharedRequest = { if (message.kind !== "") { writer.uint32(18).string(message.kind); } + if (message.pagination !== undefined) { + CursorPaginationRequest.encode(message.pagination, writer.uint32(26).fork()).ldelim(); + } return writer; }, @@ -181,6 +209,13 @@ export const DiscoverPublicSharedRequest = { message.kind = reader.string(); continue; + case 3: + if (tag !== 26) { + break; + } + + message.pagination = CursorPaginationRequest.decode(reader, reader.uint32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -194,6 +229,7 @@ export const DiscoverPublicSharedRequest = { return { digest: isSet(object.digest) ? String(object.digest) : "", kind: isSet(object.kind) ? String(object.kind) : "", + pagination: isSet(object.pagination) ? CursorPaginationRequest.fromJSON(object.pagination) : undefined, }; }, @@ -201,6 +237,8 @@ export const DiscoverPublicSharedRequest = { const obj: any = {}; message.digest !== undefined && (obj.digest = message.digest); message.kind !== undefined && (obj.kind = message.kind); + message.pagination !== undefined && + (obj.pagination = message.pagination ? CursorPaginationRequest.toJSON(message.pagination) : undefined); return obj; }, @@ -212,12 +250,15 @@ export const DiscoverPublicSharedRequest = { const message = createBaseDiscoverPublicSharedRequest(); message.digest = object.digest ?? ""; message.kind = object.kind ?? ""; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? CursorPaginationRequest.fromPartial(object.pagination) + : undefined; return message; }, }; function createBaseDiscoverPublicSharedResponse(): DiscoverPublicSharedResponse { - return { result: undefined }; + return { result: undefined, pagination: undefined }; } export const DiscoverPublicSharedResponse = { @@ -225,6 +266,9 @@ export const DiscoverPublicSharedResponse = { if (message.result !== undefined) { ReferrerItem.encode(message.result, writer.uint32(10).fork()).ldelim(); } + if (message.pagination !== undefined) { + CursorPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } return writer; }, @@ -242,6 +286,13 @@ export const DiscoverPublicSharedResponse = { message.result = ReferrerItem.decode(reader, reader.uint32()); continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = CursorPaginationResponse.decode(reader, reader.uint32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -252,12 +303,17 @@ export const DiscoverPublicSharedResponse = { }, fromJSON(object: any): DiscoverPublicSharedResponse { - return { result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined }; + return { + result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined, + pagination: isSet(object.pagination) ? CursorPaginationResponse.fromJSON(object.pagination) : undefined, + }; }, toJSON(message: DiscoverPublicSharedResponse): unknown { const obj: any = {}; message.result !== undefined && (obj.result = message.result ? ReferrerItem.toJSON(message.result) : undefined); + message.pagination !== undefined && + (obj.pagination = message.pagination ? CursorPaginationResponse.toJSON(message.pagination) : undefined); return obj; }, @@ -270,12 +326,15 @@ export const DiscoverPublicSharedResponse = { message.result = (object.result !== undefined && object.result !== null) ? ReferrerItem.fromPartial(object.result) : undefined; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? CursorPaginationResponse.fromPartial(object.pagination) + : undefined; return message; }, }; function createBaseReferrerServiceDiscoverPrivateResponse(): ReferrerServiceDiscoverPrivateResponse { - return { result: undefined }; + return { result: undefined, pagination: undefined }; } export const ReferrerServiceDiscoverPrivateResponse = { @@ -283,6 +342,9 @@ export const ReferrerServiceDiscoverPrivateResponse = { if (message.result !== undefined) { ReferrerItem.encode(message.result, writer.uint32(10).fork()).ldelim(); } + if (message.pagination !== undefined) { + CursorPaginationResponse.encode(message.pagination, writer.uint32(18).fork()).ldelim(); + } return writer; }, @@ -300,6 +362,13 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.result = ReferrerItem.decode(reader, reader.uint32()); continue; + case 2: + if (tag !== 18) { + break; + } + + message.pagination = CursorPaginationResponse.decode(reader, reader.uint32()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -310,12 +379,17 @@ export const ReferrerServiceDiscoverPrivateResponse = { }, fromJSON(object: any): ReferrerServiceDiscoverPrivateResponse { - return { result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined }; + return { + result: isSet(object.result) ? ReferrerItem.fromJSON(object.result) : undefined, + pagination: isSet(object.pagination) ? CursorPaginationResponse.fromJSON(object.pagination) : undefined, + }; }, toJSON(message: ReferrerServiceDiscoverPrivateResponse): unknown { const obj: any = {}; message.result !== undefined && (obj.result = message.result ? ReferrerItem.toJSON(message.result) : undefined); + message.pagination !== undefined && + (obj.pagination = message.pagination ? CursorPaginationResponse.toJSON(message.pagination) : undefined); return obj; }, @@ -332,6 +406,9 @@ export const ReferrerServiceDiscoverPrivateResponse = { message.result = (object.result !== undefined && object.result !== null) ? ReferrerItem.fromPartial(object.result) : undefined; + message.pagination = (object.pagination !== undefined && object.pagination !== null) + ? CursorPaginationResponse.fromPartial(object.pagination) + : undefined; return message; }, }; 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 46b133e7f..08efa148f 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.jsonschema.json @@ -12,6 +12,10 @@ "kind": { "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\n Used to filter and resolve ambiguities", "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.CursorPaginationRequest.jsonschema.json", + "description": "Pagination options for the references list" } }, "title": "Discover Public Shared Request", 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 675b824de..0a175476f 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedRequest.schema.json @@ -12,6 +12,10 @@ "kind": { "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\n Used to filter and resolve ambiguities", "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.CursorPaginationRequest.schema.json", + "description": "Pagination options for the references list" } }, "title": "Discover Public Shared Request", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.jsonschema.json index f5fd80ff1..ebb28c383 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.jsonschema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "DiscoverPublicSharedResponse is the response for the DiscoverPublicShared method", "properties": { + "pagination": { + "$ref": "controlplane.v1.CursorPaginationResponse.jsonschema.json", + "description": "Pagination information for the references list" + }, "result": { "$ref": "controlplane.v1.ReferrerItem.jsonschema.json", "description": "Result is the discovered referrer item" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.schema.json index bee5d51af..282690659 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.DiscoverPublicSharedResponse.schema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "DiscoverPublicSharedResponse is the response for the DiscoverPublicShared method", "properties": { + "pagination": { + "$ref": "controlplane.v1.CursorPaginationResponse.schema.json", + "description": "Pagination information for the references list" + }, "result": { "$ref": "controlplane.v1.ReferrerItem.schema.json", "description": "Result is the discovered referrer item" 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 dee59c0db..535b0abfb 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.jsonschema.json @@ -12,6 +12,10 @@ "kind": { "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\n Used to filter and resolve ambiguities", "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.CursorPaginationRequest.jsonschema.json", + "description": "Pagination options for the references list" } }, "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 0fbb61a8e..339c19728 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateRequest.schema.json @@ -12,6 +12,10 @@ "kind": { "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\n Used to filter and resolve ambiguities", "type": "string" + }, + "pagination": { + "$ref": "controlplane.v1.CursorPaginationRequest.schema.json", + "description": "Pagination options for the references list" } }, "title": "Referrer Service Discover Private Request", diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json index 7e319fc37..da7c32ffc 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.jsonschema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", "properties": { + "pagination": { + "$ref": "controlplane.v1.CursorPaginationResponse.jsonschema.json", + "description": "Pagination information for the references list" + }, "result": { "$ref": "controlplane.v1.ReferrerItem.jsonschema.json", "description": "Result is the discovered referrer item" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json index 7d70255ca..88440dcfa 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverPrivateResponse.schema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "ReferrerServiceDiscoverPrivateResponse is the response for the DiscoverPrivate method", "properties": { + "pagination": { + "$ref": "controlplane.v1.CursorPaginationResponse.schema.json", + "description": "Pagination information for the references list" + }, "result": { "$ref": "controlplane.v1.ReferrerItem.schema.json", "description": "Result is the discovered referrer item" diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index 7e5103467..c781887c2 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -42,6 +42,16 @@ paths: name: kind schema: type: string + - in: query + name: pagination.cursor + schema: + type: string + - description: Limit pagination to 100 + in: query + name: pagination.limit + schema: + format: int32 + type: integer responses: '200': content: @@ -82,6 +92,16 @@ paths: name: kind schema: type: string + - in: query + name: pagination.cursor + schema: + type: string + - description: Limit pagination to 100 + in: query + name: pagination.limit + schema: + format: int32 + type: integer responses: '200': content: @@ -228,6 +248,22 @@ components: $ref: '#/components/schemas/protobufAny' type: array type: object + v1CursorPaginationRequest: + properties: + cursor: + type: string + limit: + format: int32 + title: Limit pagination to 100 + type: integer + type: object + v1CursorPaginationResponse: + example: + next_cursor: next_cursor + properties: + next_cursor: + type: string + type: object v1DiscoverPublicSharedResponse: description: Response for the DiscoverPublicShared method example: @@ -244,9 +280,13 @@ components: created_at: '2000-01-23T04:56:07.000+00:00' annotations: key: annotations + pagination: + next_cursor: next_cursor properties: result: $ref: '#/components/schemas/v1ReferrerItem' + pagination: + $ref: '#/components/schemas/v1CursorPaginationResponse' title: DiscoverPublicSharedResponse type: object v1ReferrerItem: @@ -320,9 +360,13 @@ components: created_at: '2000-01-23T04:56:07.000+00:00' annotations: key: annotations + pagination: + next_cursor: next_cursor properties: result: $ref: '#/components/schemas/v1ReferrerItem' + pagination: + $ref: '#/components/schemas/v1CursorPaginationResponse' title: ReferrerServiceDiscoverPrivateResponse type: object securitySchemes: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 2f31bb280..4b0fccea4 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/google/uuid" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -50,11 +51,17 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS return nil, err } + paginationOpts, err := referrerPaginationOptsFromProto(req.GetPagination()) + if err != nil { + return nil, err + } + // 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, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID) + referrer, nextCursor, err = s.referrerUC.GetFromRootUser(ctx, req.GetDigest(), req.GetKind(), currentUser.ID, paginationOpts) } else if currentToken != nil { var orgUUID uuid.UUID orgUUID, err = uuid.Parse(currentOrg.ID) @@ -69,28 +76,50 @@ func (s *ReferrerService) DiscoverPrivate(ctx context.Context, req *pb.ReferrerS orgsProjectsMap[orgUUID] = visibleProjects } - referrer, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, orgsProjectsMap) + referrer, nextCursor, err = s.referrerUC.GetFromRoot(ctx, req.GetDigest(), req.GetKind(), []uuid.UUID{orgUUID}, orgsProjectsMap, paginationOpts) } if err != nil { return nil, handleUseCaseErr(err, s.log) } return &pb.ReferrerServiceDiscoverPrivateResponse{ - Result: bizReferrerToPb(referrer), + Result: bizReferrerToPb(referrer), + Pagination: bizCursorToPb(nextCursor), }, nil } func (s *ReferrerService) DiscoverPublicShared(ctx context.Context, req *pb.DiscoverPublicSharedRequest) (*pb.DiscoverPublicSharedResponse, error) { - res, err := s.referrerUC.GetFromRootInPublicSharedIndex(ctx, req.GetDigest(), req.GetKind()) + paginationOpts, err := referrerPaginationOptsFromProto(req.GetPagination()) + if err != nil { + return nil, err + } + + res, nextCursor, err := s.referrerUC.GetFromRootInPublicSharedIndex(ctx, req.GetDigest(), req.GetKind(), paginationOpts) if err != nil { return nil, handleUseCaseErr(err, s.log) } return &pb.DiscoverPublicSharedResponse{ - Result: bizReferrerToPb(res), + Result: bizReferrerToPb(res), + Pagination: bizCursorToPb(nextCursor), }, nil } +const defaultReferrerPageSize = 20 + +// referrerPaginationOptsFromProto converts the proto pagination request to cursor options. +// Returns nil when the request has no pagination, preserving backward compatibility (all references returned). +func referrerPaginationOptsFromProto(p *pb.CursorPaginationRequest) (*pagination.CursorOptions, error) { + if p == nil { + return nil, nil + } + limit := int(p.GetLimit()) + if limit == 0 { + limit = defaultReferrerPageSize + } + return pagination.NewCursor(p.GetCursor(), limit) +} + func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem { item := &pb.ReferrerItem{ Digest: r.Digest, diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 3caa97e9b..8eaa08667 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "time" conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" v2 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" "github.com/chainloop-dev/chainloop/pkg/servicelogger" @@ -84,10 +85,12 @@ func NewReferrerUseCase(repo ReferrerRepo, wfRepo WorkflowRepo, membershipUseCas type ReferrerRepo interface { Save(ctx context.Context, input []*Referrer, workflowID uuid.UUID) error - // GetFromRoot returns the referrer identified by the provided content digest, including its first-level references - // For example if sha:deadbeef represents an attestation, the result will contain the attestation + materials associated to it - // OrgIDs represent an allowList of organizations where the referrers should be looked for - GetFromRoot(ctx context.Context, digest string, orgIDs []uuid.UUID, filters ...GetFromRootFilter) (*StoredReferrer, error) + // GetFromRoot returns the referrer identified by the provided content digest, including its first-level references. + // For example if sha:deadbeef represents an attestation, the result will contain the attestation + materials associated to it. + // OrgIDs represent an allowList of organizations where the referrers should be looked for. + // Pagination controls how many references are returned and supports cursor-based pagination. + // Returns the referrer, a next cursor string (empty if no more pages), and an error. + GetFromRoot(ctx context.Context, digest string, orgIDs []uuid.UUID, p *pagination.CursorOptions, filters ...GetFromRootFilter) (*StoredReferrer, string, error) // Exist Checks if a given referrer by digest exist. // The query can be scoped further down if needed by providing the kind or visibility status Exist(ctx context.Context, digest string, filters ...GetFromRootFilter) (bool, error) @@ -176,27 +179,27 @@ func (s *ReferrerUseCase) ExtractAndPersist(ctx context.Context, att *dsse.Envel return nil } -// GetFromRootUser returns the referrer identified by the provided content digest, including its first-level references -// 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) (*StoredReferrer, error) { +// 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) { userUUID, err := uuid.Parse(userID) if err != nil { - return nil, NewErrInvalidUUID(err) + return nil, "", NewErrInvalidUUID(err) } userOrgs, projectIDs, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) if err != nil { - return nil, err + return nil, "", err } // 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) + return s.GetFromRoot(ctx, digest, rootKind, userOrgs, projectIDs, p) } -func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID) (*StoredReferrer, error) { +func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind string, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, p *pagination.CursorOptions) (*StoredReferrer, string, error) { filters := make([]GetFromRootFilter, 0) if rootKind != "" { filters = append(filters, WithKind(rootKind)) @@ -205,26 +208,26 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri filters = append(filters, WithVisibleProjectIDs(projectIDs)) } - ref, err := s.repo.GetFromRoot(ctx, digest, orgIDs, filters...) + ref, nextCursor, err := s.repo.GetFromRoot(ctx, digest, orgIDs, p, filters...) if err != nil { if errors.As(err, &ErrAmbiguousReferrer{}) { - return nil, NewErrValidation(fmt.Errorf("please provide the referrer kind: %w", err)) + return nil, "", NewErrValidation(fmt.Errorf("please provide the referrer kind: %w", err)) } - return nil, fmt.Errorf("getting referrer from root: %w", err) + return nil, "", fmt.Errorf("getting referrer from root: %w", err) } else if ref == nil { - return nil, NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) + return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) } - return ref, nil + return ref, nextCursor, nil } // Get the list of public referrers from organizations // that have been allowed to be shown in a shared index // NOTE: This is a public endpoint under /discover/[sha256:deadbeef] -func (s *ReferrerUseCase) GetFromRootInPublicSharedIndex(ctx context.Context, digest, rootKind string) (*StoredReferrer, error) { +func (s *ReferrerUseCase) GetFromRootInPublicSharedIndex(ctx context.Context, digest, rootKind string, p *pagination.CursorOptions) (*StoredReferrer, string, error) { if s.indexConfig == nil || !s.indexConfig.Enabled { - return nil, NewErrUnauthorizedStr("shared referrer index functionality is not enabled") + return nil, "", NewErrUnauthorizedStr("shared referrer index functionality is not enabled") } // Load the organizations that are allowed to appear in the shared index @@ -232,7 +235,7 @@ func (s *ReferrerUseCase) GetFromRootInPublicSharedIndex(ctx context.Context, di for _, orgID := range s.indexConfig.AllowedOrgs { orgUUID, err := uuid.Parse(orgID) if err != nil { - return nil, NewErrInvalidUUID(err) + return nil, "", NewErrInvalidUUID(err) } orgIDs = append(orgIDs, orgUUID) } @@ -243,18 +246,18 @@ func (s *ReferrerUseCase) GetFromRootInPublicSharedIndex(ctx context.Context, di filters = append(filters, WithKind(rootKind)) } - ref, err := s.repo.GetFromRoot(ctx, digest, orgIDs, filters...) + ref, nextCursor, err := s.repo.GetFromRoot(ctx, digest, orgIDs, p, filters...) if err != nil { if errors.As(err, &ErrAmbiguousReferrer{}) { - return nil, NewErrValidation(fmt.Errorf("please provide the referrer kind: %w", err)) + return nil, "", NewErrValidation(fmt.Errorf("please provide the referrer kind: %w", err)) } - return nil, fmt.Errorf("getting referrer from root: %w", err) + return nil, "", fmt.Errorf("getting referrer from root: %w", err) } else if ref == nil { - return nil, NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) + return nil, "", NewErrNotFound(fmt.Sprintf("artifact or piece of evidence with digest %s", digest)) } - return ref, nil + return ref, nextCursor, nil } const ( diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index e3f48fb86..bed479eef 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination" "github.com/chainloop-dev/chainloop/pkg/credentials" creds "github.com/chainloop-dev/chainloop/pkg/credentials/mocks" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -52,17 +53,17 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootInPublicSharedIndex() { // We'll store the attestation in the private only index ctx := context.Background() s.Run("public endpoint fails if feature not enabled", func() { - _, err := s.Referrer.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "") + _, _, err := s.Referrer.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "", nil) s.ErrorContains(err, "not enabled") }) s.Run("storing it associated with a private workflow keeps it private and not in the index", func() { err = s.sharedEnabledUC.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) require.NoError(s.T(), err) - ref, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID) + ref, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) s.NoError(err) s.False(ref.InPublicWorkflow) - res, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "") + res, _, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "", nil) s.True(biz.IsNotFound(err)) s.Nil(res) }) @@ -75,12 +76,12 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootInPublicSharedIndex() { err = s.sharedEnabledUC.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) require.NoError(s.T(), err) // It's marked as public in the internal index - ref, err := s.sharedEnabledUC.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID) + ref, _, err := s.sharedEnabledUC.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) s.NoError(err) s.True(ref.InPublicWorkflow) // But it's not in the public shared index because the org 2 is not whitelisted - res, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "") + res, _, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "", nil) s.True(biz.IsNotFound(err)) s.Nil(res) }) @@ -93,7 +94,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootInPublicSharedIndex() { }, nil) require.NoError(t, err) // Now it's public since org2 is whitelisted - res, err := uc.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "") + res, _, err := uc.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "", nil) s.NoError(err) s.Equal(wantReferrerAtt.Digest, res.Digest) }) @@ -108,7 +109,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootInPublicSharedIndex() { err = s.sharedEnabledUC.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) require.NoError(s.T(), err) // Now it's public since org1 is whitelisted - res, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "") + res, _, err := s.sharedEnabledUC.GetFromRootInPublicSharedIndex(ctx, wantReferrerAtt.Digest, "", nil) s.NoError(err) s.Equal(wantReferrerAtt.Digest, res.Digest) }) @@ -138,11 +139,12 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsDependentAttestatio err = s.Referrer.ExtractAndPersist(ctx, envelope, wantReferrerAtt, s.workflow1.ID.String()) s.NoError(err) - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.String(), "ATTESTATION", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.String(), "ATTESTATION", s.user.ID, nil) s.NoError(err) // It has a commit and an attestation require.Len(s.T(), got.References, 2) - s.Equal(wantDependentAtt.String(), got.References[0].Digest) + digests := []string{got.References[0].Digest, got.References[1].Digest} + s.Contains(digests, wantDependentAtt.String()) }) } @@ -166,7 +168,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsConcurrency() { } wg.Wait() - got, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) s.NoError(err) s.Len(got.WorkflowIDs, 2) s.Equal([]uuid.UUID{s.workflow2.ID, s.workflow1.ID}, got.WorkflowIDs) @@ -229,21 +231,21 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("it can store properly the first time", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) s.NoError(err) - prevStoredRef, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID) + prevStoredRef, _, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) s.NoError(err) }) s.T().Run("and it's idempotent", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow1.ID.String()) s.NoError(err) - ref, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID) + ref, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) s.NoError(err) // Check it's the same referrer than previously retrieved, including timestamps s.Equal(prevStoredRef, ref) }) s.T().Run("contains all the info", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) s.NoError(err) // parent i.e attestation s.Equal(wantReferrerAtt.Digest, got.Digest) @@ -265,24 +267,33 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { // it has all the references require.Len(t, got.References, 6) - for i, want := range []*biz.Referrer{ - wantReferrerArtifact, wantReferrerContainerImage, wantReferrerCommit, wantReferrerOpenVEX, wantReferrerSarif, wantReferrerSBOM} { - gotR := got.References[i] - s.Equal(want, gotR.Referrer) + wantRefs := []*biz.Referrer{ + wantReferrerArtifact, wantReferrerContainerImage, wantReferrerCommit, wantReferrerOpenVEX, wantReferrerSarif, wantReferrerSBOM, + } + for _, want := range wantRefs { + found := false + for _, gotR := range got.References { + if gotR.Digest == want.Digest { + s.Equal(want, gotR.Referrer) + found = true + break + } + } + s.True(found, "expected referrer with digest %s not found", want.Digest) } s.Equal([]uuid.UUID{s.org1UUID}, got.OrgIDs) s.Equal([]uuid.UUID{s.workflow1.ID}, got.WorkflowIDs) }) s.T().Run("can get sha1 digests too", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerCommit.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerCommit.Digest, "", s.user.ID, nil) s.NoError(err) s.Equal(wantReferrerCommit.Digest, got.Digest) }) s.T().Run("can't be accessed by a second user in another org", func(t *testing.T) { // the user2 has not access to org1 - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user2.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user2.ID, nil) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -290,7 +301,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("but another workflow can be attached", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) s.Contains(got.OrgIDs, s.org1UUID) @@ -299,7 +310,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { // and it's idempotent (no new orgs added) err = s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID) + got, _, err = s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) s.Equal([]uuid.UUID{s.org2UUID, s.org1UUID}, got.OrgIDs) @@ -309,13 +320,13 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("and now user2 has access to it since it has access to workflow2 in org2", func(t *testing.T) { err := s.Referrer.ExtractAndPersist(ctx, envelope, h, s.workflow2.ID.String()) s.NoError(err) - got, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user2.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user2.ID, nil) s.NoError(err) require.Len(t, got.OrgIDs, 2) }) s.T().Run("subject materials are returned connected to the attestation", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID, nil) s.NoError(err) // parent i.e attestation s.Equal(wantReferrerContainerImage.Digest, got.Digest) @@ -329,7 +340,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("non-subject materials also are connected to the attestation", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID, nil) s.NoError(err) require.Len(t, got.References, 1) s.Equal(wantReferrerAtt.Digest, got.References[0].Digest) @@ -338,7 +349,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("or it does not exist", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", s.user.ID, nil) s.True(biz.IsNotFound(err)) s.Nil(got) }) @@ -360,20 +371,20 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.NoError(err) // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "", s.user.ID, nil) s.Nil(got) s.ErrorContains(err, "present in 2 kinds") }) s.T().Run("it should not fail on retrieval if we filter out by one kind", func(t *testing.T) { // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "SARIF", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "SARIF", s.user.ID, nil) s.NoError(err) s.Equal(wantReferrerSarif.Digest, got.Digest) s.Equal(true, got.Downloadable) s.Equal("SARIF", got.Kind) - got, err = s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "ARTIFACT", s.user.ID) + got, _, err = s.Referrer.GetFromRootUser(ctx, wantReferrerSarif.Digest, "ARTIFACT", s.user.ID, nil) s.NoError(err) s.Equal(wantReferrerSarif.Digest, got.Digest) s.Equal(true, got.Downloadable) @@ -382,18 +393,20 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("now there should a container image pointing to two attestations", func(t *testing.T) { // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID, nil) s.NoError(err) // it should be referenced by two attestations since it's subject of both require.Len(t, got.References, 2) - s.Equal("ATTESTATION", got.References[0].Kind) - s.Equal("sha256:2e9bf8e13acd112eff355787b2b72eb8af4ee51fc22c7e65611939f2225e1dc5", got.References[0].Digest) - s.Equal("ATTESTATION", got.References[1].Kind) - s.Equal("sha256:5f4d1baadaf3e439f769f11c7ba0c5f77dad27d00689144d1311b48e65818bbd", got.References[1].Digest) + gotDigests := []string{got.References[0].Digest, got.References[1].Digest} + for _, ref := range got.References { + s.Equal("ATTESTATION", ref.Kind) + } + s.Contains(gotDigests, "sha256:2e9bf8e13acd112eff355787b2b72eb8af4ee51fc22c7e65611939f2225e1dc5") + s.Contains(gotDigests, "sha256:5f4d1baadaf3e439f769f11c7ba0c5f77dad27d00689144d1311b48e65818bbd") }) s.T().Run("if all associated workflows are private, the referrer is private", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) s.NoError(err) s.False(got.InPublicWorkflow) s.Equal([]uuid.UUID{s.workflow2.ID, s.workflow1.ID}, got.WorkflowIDs) @@ -407,7 +420,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { _, err := s.Workflow.Update(ctx, s.org1.ID, s.workflow1.ID.String(), &biz.WorkflowUpdateOpts{Public: toPtrBool(true)}) require.NoError(t, err) - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID) + got, _, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.Digest, "", s.user.ID, nil) s.NoError(err) s.True(got.InPublicWorkflow) for _, r := range got.References { @@ -416,6 +429,108 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) } +func (s *referrerIntegrationTestSuite) TestPagination() { + // Load attestation — it has 6 references (ARTIFACT, CONTAINER_IMAGE, GIT_HEAD_COMMIT, OPENVEX, SARIF, SBOM_CYCLONEDX_JSON) + 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) + + tests := []struct { + name string + limit int + cursor string + wantCount int + wantNextCursor bool + description string + }{ + { + name: "nil pagination returns all references (no limit)", + wantCount: 6, + wantNextCursor: false, + description: "backward compat: nil pagination returns everything", + }, + { + name: "limit larger than total returns all", + limit: 100, + wantCount: 6, + wantNextCursor: false, + }, + { + name: "limit equal to total returns all with no next cursor", + limit: 6, + wantCount: 6, + wantNextCursor: false, + }, + { + name: "limit less than total returns partial with next cursor", + limit: 3, + wantCount: 3, + wantNextCursor: true, + }, + { + name: "limit of 1 returns single reference with next cursor", + limit: 1, + wantCount: 1, + wantNextCursor: true, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + var p *pagination.CursorOptions + if tc.limit > 0 { + p, err = pagination.NewCursor(tc.cursor, tc.limit) + require.NoError(t, err) + } + + got, nextCursor, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, p) + require.NoError(t, err) + require.Len(t, got.References, tc.wantCount) + + if tc.wantNextCursor { + s.NotEmpty(nextCursor, "expected a next cursor") + } else { + s.Empty(nextCursor, "expected no next cursor") + } + }) + } + + // Test cursor traversal: page through all references one at a time + s.T().Run("cursor traversal visits all references", func(t *testing.T) { + var allDigests []string + cursor := "" + + for i := 0; i < 10; i++ { // safety limit + p, err := pagination.NewCursor(cursor, 1) + require.NoError(t, err) + + got, nextCursor, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, p) + require.NoError(t, err) + require.Len(t, got.References, 1) + + allDigests = append(allDigests, got.References[0].Digest) + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + // Should have traversed all 6 references + s.Len(allDigests, 6) + // All digests should be unique + seen := make(map[string]bool) + for _, d := range allDigests { + s.False(seen[d], "duplicate digest found: %s", d) + seen[d] = true + } + }) +} + 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 4565e006b..6c2b4a78a 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -20,12 +20,14 @@ import ( "fmt" "slices" + "entgo.io/ent/dialect/sql" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/predicate" "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/pagination" "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" "golang.org/x/exp/maps" @@ -131,7 +133,7 @@ func (r *ReferrerRepo) Exist(ctx context.Context, digest string, filters ...biz. return query.Exist(ctx) } -func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs []uuid.UUID, filters ...biz.GetFromRootFilter) (*biz.StoredReferrer, error) { +func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs []uuid.UUID, p *pagination.CursorOptions, filters ...biz.GetFromRootFilter) (*biz.StoredReferrer, string, error) { opts := &biz.GetFromRootFilters{} for _, f := range filters { f(opts) @@ -164,28 +166,28 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] 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) + return nil, "", fmt.Errorf("failed to query referrer: %w", err) } // No items found if numrefs := len(refs); numrefs == 0 { - return nil, nil + return nil, "", nil } else if numrefs > 1 { // if there is more than 1 item with the same digest+artifactType we will fail var kinds []string for _, r := range refs { kinds = append(kinds, r.Kind) } - return nil, biz.NewErrReferrerAmbiguous(digest, kinds) + return nil, "", biz.NewErrReferrerAmbiguous(digest, kinds) } // Find the referrer recursively starting from the root - res, err := r.doGet(ctx, refs[0], orgIDs, opts.ProjectIDs, opts.Public, 0) + res, nextCursor, err := r.doGet(ctx, refs[0], orgIDs, opts.ProjectIDs, opts.Public, p, 0) if err != nil && !biz.IsErrUnauthorized(err) { - return nil, fmt.Errorf("failed to get referrer: %w", err) + return nil, "", fmt.Errorf("failed to get referrer: %w", err) } - return res, nil + return res, nextCursor, nil } // max number of recursive levels to traverse @@ -193,7 +195,7 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] // 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, level int) (*biz.StoredReferrer, error) { +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) { // Assemble the referrer to return res := &biz.StoredReferrer{ ID: root.ID, @@ -212,12 +214,12 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // check that, if RBAC is required, the user has visibility on the artifact in at least 1 org/project if visible := isReferrerVisible(res, allowedOrgs, visibleProjectsMap); !visible { - return nil, biz.NewErrUnauthorizedStr("referrer not allowed") + return nil, "", biz.NewErrUnauthorizedStr("referrer not allowed") } // Next: We'll find the references recursively up to a max of maxTraverseLevels levels if level >= maxTraverseLevels { - return res, nil + return res, "", nil } // Find the references and call recursively filtered out by the allowed organizations @@ -236,20 +238,44 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // Attach the workflow predicate predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...)) - // sort the references by creation date in descending order - // so whenever we add pagination we'll get the latest x references - refs, err := root.QueryReferences().Where(predicateReferrer...).WithWorkflows().Order(referrer.ByCreatedAt(), ent.Desc()).All(ctx) + // Sort references by creation date and ID in descending order for deterministic pagination + q := root.QueryReferences().Where(predicateReferrer...).WithWorkflows(). + Order(referrer.ByCreatedAt(sql.OrderDesc())). + Order(referrer.ByID(sql.OrderDesc())) + + // Apply pagination: fetch limit+1 to detect next page + if p != nil { + q = q.Limit(p.Limit + 1) + + if p.Cursor != nil { + q = q.Where(func(s *sql.Selector) { + s.Where(sql.CompositeLT( + []string{s.C(referrer.FieldCreatedAt), s.C(referrer.FieldID)}, + p.Cursor.Timestamp, p.Cursor.ID, + )) + }) + } + } + + refs, err := q.All(ctx) if err != nil { - return nil, fmt.Errorf("failed to query references: %w", err) + return nil, "", fmt.Errorf("failed to query references: %w", err) + } + + // Determine if there is a next page and encode the cursor + var nextCursor string + if p != nil && len(refs) > p.Limit { + lastVisible := refs[p.Limit-1] + nextCursor = pagination.EncodeCursor(lastVisible.CreatedAt, lastVisible.ID) + refs = refs[:p.Limit] } // Add the references to the result for _, reference := range refs { - // Call recursively the function - // we return all the references - ref, err := r.doGet(ctx, reference, allowedOrgs, visibleProjectsMap, public, level+1) + // Call recursively the function — pagination only applies to the first level + ref, _, err := r.doGet(ctx, reference, allowedOrgs, visibleProjectsMap, public, nil, level+1) if err != nil && !biz.IsErrUnauthorized(err) { - return nil, fmt.Errorf("failed to get referrer: %w", err) + return nil, "", fmt.Errorf("failed to get referrer: %w", err) } if ref != nil { @@ -257,7 +283,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg } } - return res, nil + return res, nextCursor, nil } func isReferrerVisible(ref *biz.StoredReferrer, allowedOrgs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) bool {