From 11dc598b1860dbc81510befa2299a54ba12c5f6f Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 20 Mar 2026 14:07:30 +0100 Subject: [PATCH 1/6] feat(referrer): add cursor-based pagination to discover endpoints Add optional cursor-based pagination to DiscoverPrivate and DiscoverPublicShared RPCs. References inside ReferrerItem are now paginated using keyset pagination on (created_at, id), defaulting to 20 items per page. Existing clients that omit pagination get the first page transparently. CLI gains --limit and --next flags on `referrer discover`. Signed-off-by: Miguel Martinez Trivino Entire-Checkpoint: 2495d6f80461 --- app/cli/cmd/referrer_discover.go | 27 ++- app/cli/pkg/action/referrer_discover.go | 40 ++++- .../api/controlplane/v1/referrer.pb.go | 113 +++++++++---- .../api/controlplane/v1/referrer.proto | 11 +- .../api/controlplane/v1/referrer_grpc.pb.go | 2 +- .../gen/frontend/controlplane/v1/referrer.ts | 89 +++++++++- ...iscoverPublicSharedRequest.jsonschema.json | 4 + ...v1.DiscoverPublicSharedRequest.schema.json | 4 + ...scoverPublicSharedResponse.jsonschema.json | 4 + ...1.DiscoverPublicSharedResponse.schema.json | 4 + ...viceDiscoverPrivateRequest.jsonschema.json | 4 + ...rServiceDiscoverPrivateRequest.schema.json | 4 + ...iceDiscoverPrivateResponse.jsonschema.json | 4 + ...ServiceDiscoverPrivateResponse.schema.json | 4 + app/controlplane/api/gen/openapi/openapi.yaml | 44 +++++ app/controlplane/internal/service/referrer.go | 38 ++++- app/controlplane/pkg/biz/referrer.go | 55 +++--- .../pkg/biz/referrer_integration_test.go | 157 +++++++++++++++--- app/controlplane/pkg/data/referrer.go | 66 +++++--- 19 files changed, 546 insertions(+), 128 deletions(-) diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index 768169ed2..94c6d9843 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 { @@ -45,7 +52,16 @@ func newReferrerDiscoverCmd() *cobra.Command { } // NOTE: this is a preview/beta command, for now we only return JSON format - return output.EncodeJSON(res) + if err := output.EncodeJSON(res.Item); err != nil { + return err + } + + if next := res.NextCursor; next != "" { + logger.Info().Msg("Pagination options \n") + logger.Info().Msgf("--next %s\n", next) + } + + return nil }, } @@ -55,6 +71,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/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 5dc4d49e8..5e6590598 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 + NextCursor string +} + 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..f4237c839 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,47 @@ 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. +// It always returns non-nil options, defaulting to limit=20 when no pagination is provided. +func referrerPaginationOptsFromProto(p *pb.CursorPaginationRequest) (*pagination.CursorOptions, error) { + 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..8e57fae77 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,7 +139,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsDependentAttestatio err = s.Referrer.ExtractAndPersist(ctx, envelope, wantReferrerAtt, s.workflow1.ID.String()) s.NoError(err) - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerAtt.String(), "ATTESTATION", s.user.ID) + 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) @@ -166,7 +167,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 +230,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) @@ -275,14 +276,14 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) s.T().Run("can get sha1 digests too", func(t *testing.T) { - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerCommit.Digest, "", s.user.ID) + 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 +291,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 +300,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 +310,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 +330,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 +339,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 +361,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,7 +383,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { s.T().Run("now there should a container image pointing to two attestations", func(t *testing.T) { // but retrieval should fail. In the future we will ask the user to provide the artifact type in these cases of ambiguity - got, err := s.Referrer.GetFromRootUser(ctx, wantReferrerContainerImage.Digest, "", s.user.ID) + 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) @@ -393,7 +394,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { }) 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 +408,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 +417,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..1f199a95b 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(), ent.Desc()). + Order(referrer.ByID(), ent.Desc()) + + // 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 { From 7a145abc321dc738e0488496c60dc3703c2814b9 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 20 Mar 2026 22:22:42 +0100 Subject: [PATCH 2/6] fix(referrer): correct ORDER BY direction for cursor-based pagination ent.Desc() with no arguments is a no-op, so the query was ordering ASC instead of DESC. This caused CompositeLT cursor comparisons to find no rows after the first page. Use ent.Desc(fields...) to match the pattern used in workflowrun.go. Also regenerate CLI docs to include the new pagination flags. Signed-off-by: Miguel Martinez Trivino --- app/cli/documentation/cli-reference.mdx | 2 ++ app/controlplane/pkg/data/referrer.go | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 1f199a95b..8f5369729 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -240,8 +240,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // Sort references by creation date and ID in descending order for deterministic pagination q := root.QueryReferences().Where(predicateReferrer...).WithWorkflows(). - Order(referrer.ByCreatedAt(), ent.Desc()). - Order(referrer.ByID(), ent.Desc()) + Order(ent.Desc(referrer.FieldCreatedAt, referrer.FieldID)) // Apply pagination: fetch limit+1 to detect next page if p != nil { From 97e338f15a2d97de59a0324ee505de3bf166f8b5 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 20 Mar 2026 22:34:50 +0100 Subject: [PATCH 3/6] fix(referrer): preserve backward compat and fix order-dependent tests - Return nil pagination opts when proto request has no pagination field, preserving backward compatibility (all references returned for old clients). - Merge CLI pagination hint into a single log message without trailing \n. - Make test assertions order-independent since DESC ordering changes reference order non-deterministically when timestamps are equal. Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/referrer_discover.go | 3 +- app/controlplane/internal/service/referrer.go | 11 +++---- .../pkg/biz/referrer_integration_test.go | 30 +++++++++++++------ 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index 94c6d9843..92cb35724 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -57,8 +57,7 @@ func newReferrerDiscoverCmd() *cobra.Command { } if next := res.NextCursor; next != "" { - logger.Info().Msg("Pagination options \n") - logger.Info().Msgf("--next %s\n", next) + logger.Info().Msgf("To fetch the next page, use: --next %s", next) } return nil diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index f4237c839..320babbc0 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -105,16 +105,13 @@ func (s *ReferrerService) DiscoverPublicShared(ctx context.Context, req *pb.Disc }, nil } -const defaultReferrerPageSize = 20 - // referrerPaginationOptsFromProto converts the proto pagination request to cursor options. -// It always returns non-nil options, defaulting to limit=20 when no pagination is provided. +// Returns nil when the request has no pagination, preserving backward compatibility (all references returned). func referrerPaginationOptsFromProto(p *pb.CursorPaginationRequest) (*pagination.CursorOptions, error) { - limit := int(p.GetLimit()) - if limit == 0 { - limit = defaultReferrerPageSize + if p == nil { + return nil, nil } - return pagination.NewCursor(p.GetCursor(), limit) + return pagination.NewCursor(p.GetCursor(), int(p.GetLimit())) } func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 8e57fae77..cd1848a98 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -143,7 +143,8 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersistsDependentAttestatio 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()) }) } @@ -266,10 +267,19 @@ 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.Referrer.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) @@ -387,10 +397,12 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { 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) { From 666089dccd09ec49684f9e815b8b489b10b868f1 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 20 Mar 2026 23:02:25 +0100 Subject: [PATCH 4/6] fix(referrer): use sql.OrderDesc() for correct descending sort ent.Desc() without field arguments is a no-op. Use sql.OrderDesc() as OrderTermOption to ByCreatedAt/ByID for correct descending sort in pagination queries. Signed-off-by: Miguel Martinez Trivino Entire-Checkpoint: 2dc882476ce5 --- app/controlplane/pkg/data/referrer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 8f5369729..6c2b4a78a 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -240,7 +240,8 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // Sort references by creation date and ID in descending order for deterministic pagination q := root.QueryReferences().Where(predicateReferrer...).WithWorkflows(). - Order(ent.Desc(referrer.FieldCreatedAt, referrer.FieldID)) + Order(referrer.ByCreatedAt(sql.OrderDesc())). + Order(referrer.ByID(sql.OrderDesc())) // Apply pagination: fetch limit+1 to detect next page if p != nil { From 4368b754862575342790c79ac30900448e31b1c9 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 20 Mar 2026 23:04:35 +0100 Subject: [PATCH 5/6] fix(referrer): apply default page size of 20 when limit is omitted When a CursorPaginationRequest is present without an explicit limit, default to 20 instead of the global cursor default of 10. This matches the spec requirement for the referrer endpoints. Signed-off-by: Miguel Martinez Trivino Entire-Checkpoint: 7b9d8e75f733 --- app/controlplane/internal/service/referrer.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 320babbc0..4b0fccea4 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -105,13 +105,19 @@ func (s *ReferrerService) DiscoverPublicShared(ctx context.Context, req *pb.Disc }, 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 } - return pagination.NewCursor(p.GetCursor(), int(p.GetLimit())) + limit := int(p.GetLimit()) + if limit == 0 { + limit = defaultReferrerPageSize + } + return pagination.NewCursor(p.GetCursor(), limit) } func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem { From 0ab94547df1022f7258ec51ff4f3708ffb729208 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Thu, 26 Mar 2026 22:39:44 +0100 Subject: [PATCH 6/6] fix(referrer): include pagination cursor in CLI JSON output Return the full discover result (with nextCursor) in the JSON response instead of only the referrer item. Also fix staticcheck lint warning about redundant embedded field selector in integration test. Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/referrer_discover.go | 10 +--------- app/cli/pkg/action/referrer_discover.go | 4 ++-- app/controlplane/pkg/biz/referrer_integration_test.go | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/app/cli/cmd/referrer_discover.go b/app/cli/cmd/referrer_discover.go index 92cb35724..2e55ea9a0 100644 --- a/app/cli/cmd/referrer_discover.go +++ b/app/cli/cmd/referrer_discover.go @@ -52,15 +52,7 @@ func newReferrerDiscoverCmd() *cobra.Command { } // NOTE: this is a preview/beta command, for now we only return JSON format - if err := output.EncodeJSON(res.Item); err != nil { - return err - } - - if next := res.NextCursor; next != "" { - logger.Info().Msgf("To fetch the next page, use: --next %s", next) - } - - return nil + return output.EncodeJSON(res) }, } diff --git a/app/cli/pkg/action/referrer_discover.go b/app/cli/pkg/action/referrer_discover.go index 5e6590598..3f34652a0 100644 --- a/app/cli/pkg/action/referrer_discover.go +++ b/app/cli/pkg/action/referrer_discover.go @@ -41,8 +41,8 @@ type ReferrerItem struct { } type ReferrerDiscoverResult struct { - Item *ReferrerItem - NextCursor string + Item *ReferrerItem `json:"result"` + NextCursor string `json:"nextCursor,omitempty"` } func NewReferrerDiscoverPrivate(cfg *ActionsOpts) *ReferrerDiscover { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index cd1848a98..bed479eef 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -273,7 +273,7 @@ func (s *referrerIntegrationTestSuite) TestExtractAndPersists() { for _, want := range wantRefs { found := false for _, gotR := range got.References { - if gotR.Referrer.Digest == want.Digest { + if gotR.Digest == want.Digest { s.Equal(want, gotR.Referrer) found = true break