From 7204d1d3b1139e5a0253f349043b1478d8d76173 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 06:18:48 +0200 Subject: [PATCH 01/15] perf(controlplane): keep the project visibility filter a constant size The filter answering "which projects may this caller see" was built by OR-ing one condition per organization the caller belongs to. A caller in 23 organizations produced 23 OR-ed EXISTS subqueries, and a disjunction that wide stops the planner using the root's index: the surrounding referrer query became a sequential scan of every referrer in the deployment rather than a walk of the root's edges. Emit each of the two ways a caller reaches a project once for the whole set of organizations instead of once per organization, so the filter no longer grows with the caller's organization count: - every project of an organization whose role carries no project restriction, matched on the project's organization column, which needs no subquery - an individually granted project, matched as an (organization, project) pair The second branch matches pairs rather than intersecting an organization set with a project set. Intersecting them would also admit a project that is granted to the caller in one organization and happens to sit in another organization the caller is restricted in, which is not a grant the caller holds; a test pins that. Verified on a local dataset of 79,605 attestations with the caller in 23 organizations. Responses captured before and after are byte identical: 24 calls as an owner across attestation and material roots, scoped and unscoped, and 28 calls as an organization member with three project grants, including projects that must stay hidden and do. Project-scoped calls for the owner are 5.6 times faster on average, 35.8 ms to 6.4 ms. A member whose grants sit in one organization is unchanged, as expected: the old filter already collapsed to a single condition for them, so there was no fan-out to remove. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/data/referrer.go | 77 +++++++-- app/controlplane/pkg/data/referrer_test.go | 172 +++++++++++++++++++++ 2 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 app/controlplane/pkg/data/referrer_test.go diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index a642d3ac0..9fa3e87f6 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -266,26 +266,79 @@ func (r *ReferrerRepo) projectScopePredicate(projectName, version string, orgIDs // one of the allowed orgs AND, when RBAC applies to that org, the project is in the caller's // visible set. Returns nil when no org grants any project visibility, so callers can treat that // as "nothing is visible". +// +// A caller reaches a project one of two ways, so the predicate has at most two branches: +// every project of an org whose role carries no project restriction, or an individually +// granted project in an org whose role does. Each branch is emitted once for the whole set +// of orgs rather than once per org, which keeps the filter a constant size: a disjunction +// that grows with the caller's org count stops the planner from using the root's index and +// turns the surrounding referrer query into a full scan. +// +// The second branch matches (org, project) pairs rather than the two sets independently. +// Intersecting the sets would also admit a project that belongs to one of the caller's +// restricted orgs and happens to be granted in another, which is not a grant the caller +// holds. Pairs cannot express that, and they keep the branch a single condition. func projectVisibilityPredicate(orgIDs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) predicate.Project { - perOrg := make([]predicate.Project, 0, len(orgIDs)) + unrestrictedOrgs := make([]uuid.UUID, 0, len(orgIDs)) + grants := make([]projectGrant, 0, len(orgIDs)) for _, orgID := range orgIDs { - visible, hasRBAC := visibleProjectsMap[orgID] - if !hasRBAC { - perOrg = append(perOrg, project.HasOrganizationWith(organization.ID(orgID))) + visible, restricted := visibleProjectsMap[orgID] + if !restricted { + unrestrictedOrgs = append(unrestrictedOrgs, orgID) continue } - if len(visible) == 0 { - continue // RBAC applies but no project is visible in this org + // Restricted: nothing in this org is visible beyond the projects granted in it, + // so an org with no grants contributes nothing. + for _, projectID := range visible { + grants = append(grants, projectGrant{orgID: orgID, projectID: projectID}) } - perOrg = append(perOrg, project.And( - project.HasOrganizationWith(organization.ID(orgID)), - project.IDIn(visible...), - )) } - if len(perOrg) == 0 { + + predicates := make([]predicate.Project, 0, 2) + if len(unrestrictedOrgs) > 0 { + // The org is a column on the project row, so this needs no subquery. + predicates = append(predicates, project.OrganizationIDIn(unrestrictedOrgs...)) + } + if len(grants) > 0 { + predicates = append(predicates, projectGrantsPredicate(grants)) + } + + switch len(predicates) { + case 0: return nil + case 1: + return predicates[0] + default: + return project.Or(predicates...) + } +} + +// projectGrant is a project the caller may see and the org the grant was recorded under. +type projectGrant struct { + orgID, projectID uuid.UUID +} + +// projectGrantsPredicate matches a project iff it is one of the granted projects AND sits in the +// org that grant was recorded under, as a single (organization_id, id) IN ((..), (..)) condition. +func projectGrantsPredicate(grants []projectGrant) predicate.Project { + return func(s *sql.Selector) { + s.Where(sql.P().Append(func(b *sql.Builder) { + b.Wrap(func(nb *sql.Builder) { + nb.IdentComma(s.C(project.FieldOrganizationID), s.C(project.FieldID)) + }) + b.WriteString(" IN ") + b.Wrap(func(nb *sql.Builder) { + for i, g := range grants { + if i > 0 { + nb.Comma() + } + nb.Wrap(func(vb *sql.Builder) { + vb.Args(g.orgID, g.projectID) + }) + } + }) + })) } - return project.Or(perOrg...) } // max number of recursive levels to traverse diff --git a/app/controlplane/pkg/data/referrer_test.go b/app/controlplane/pkg/data/referrer_test.go new file mode 100644 index 000000000..73b77ab0f --- /dev/null +++ b/app/controlplane/pkg/data/referrer_test.go @@ -0,0 +1,172 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package data + +import ( + "fmt" + "strings" + "testing" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// renderProjectPredicate returns the SQL the predicate contributes to a query together with its +// bind arguments, so the tests can assert on the shape of the filter and on the values it carries, +// not only on the rows it would match. Values are parameterised, so they never appear in the SQL. +func renderProjectPredicate(t *testing.T, p func(*sql.Selector)) (string, []string) { + t.Helper() + sel := sql.Dialect(dialect.Postgres).Select("id").From(sql.Table(project.Table)) + p(sel) + q, args := sel.Query() + bound := make([]string, 0, len(args)) + for _, a := range args { + bound = append(bound, fmt.Sprintf("%v", a)) + } + return q, bound +} + +func TestProjectVisibilityPredicate(t *testing.T) { + orgs := make([]uuid.UUID, 0, 23) + for i := 0; i < 23; i++ { + orgs = append(orgs, uuid.New()) + } + projA, projB := uuid.New(), uuid.New() + + testCases := []struct { + name string + // input + orgIDs []uuid.UUID + rbac map[uuid.UUID][]uuid.UUID + wantNil bool + wantOrgs int // number of orgs expected in the organization IN list + // the filter must stay a constant size however many orgs the caller belongs to + wantExistsSubqueries int + wantPairs bool // the restricted branch matches (org, project) pairs + }{ + { + name: "no restrictions anywhere: a single organization IN list, no subquery", + orgIDs: orgs, + rbac: map[uuid.UUID][]uuid.UUID{}, + wantOrgs: 23, + wantExistsSubqueries: 0, + }, + { + name: "single organization behaves the same", + orgIDs: orgs[:1], + rbac: map[uuid.UUID][]uuid.UUID{}, + wantOrgs: 1, + wantExistsSubqueries: 0, + }, + { + name: "restricted everywhere: one pair list for every grant", + orgIDs: orgs[:2], + rbac: map[uuid.UUID][]uuid.UUID{orgs[0]: {projA}, orgs[1]: {projB}}, + wantExistsSubqueries: 0, + wantPairs: true, + }, + { + name: "mixed: one organization list and one pair list", + orgIDs: orgs[:3], + rbac: map[uuid.UUID][]uuid.UUID{orgs[0]: {projA}}, + wantOrgs: 2, + wantExistsSubqueries: 0, + wantPairs: true, + }, + { + name: "RBAC applies but nothing is visible: no predicate at all", + orgIDs: orgs[:2], + rbac: map[uuid.UUID][]uuid.UUID{orgs[0]: {}, orgs[1]: {}}, + wantNil: true, + }, + { + name: "no organizations at all", + orgIDs: nil, + rbac: map[uuid.UUID][]uuid.UUID{}, + wantNil: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := projectVisibilityPredicate(tc.orgIDs, tc.rbac) + if tc.wantNil { + assert.Nil(t, got) + return + } + require.NotNil(t, got) + + query, _ := renderProjectPredicate(t, got) + // A condition per organization is the regression this guards against. + assert.Equal(t, tc.wantExistsSubqueries, strings.Count(query, "EXISTS"), + "the filter must not grow a subquery per organization: %s", query) + if tc.wantOrgs > 0 { + assert.Contains(t, query, `"projects"."organization_id" IN (`, + "organizations must be matched with a single IN list: %s", query) + } + if tc.wantPairs { + assert.Contains(t, query, `("projects"."organization_id", "projects"."id") IN (`, + "granted projects must be matched as (org, project) pairs: %s", query) + } + }) + } +} + +// TestProjectVisibilityPredicateDoesNotCrossOrgs pins the property the pair matching exists for: +// a project granted to the caller in one org must not become visible because it sits in a +// different org the caller is also restricted in. Matching the org set and the project set +// independently would admit exactly that. +func TestProjectVisibilityPredicateDoesNotCrossOrgs(t *testing.T) { + orgA, orgB := uuid.New(), uuid.New() + grantedInA, grantedInB := uuid.New(), uuid.New() + + got := projectVisibilityPredicate( + []uuid.UUID{orgA, orgB}, + map[uuid.UUID][]uuid.UUID{orgA: {grantedInA}, orgB: {grantedInB}}, + ) + require.NotNil(t, got) + query, args := renderProjectPredicate(t, got) + + assert.Contains(t, query, `("projects"."organization_id", "projects"."id") IN (`, + "the grants are matched as pairs, not as two independent sets: %s", query) + // Each project is bound next to the org it was granted in, and to no other. + assert.Equal(t, []string{orgA.String(), grantedInA.String(), orgB.String(), grantedInB.String()}, args, + "every grant must keep its own organization") + assert.NotContains(t, query, `"projects"."id" IN (`, + "a bare project list would match a granted project in any of the caller's restricted orgs: %s", query) +} + +// TestProjectVisibilityPredicateSkipsOrgsWithoutGrants keeps the behaviour that a restricted org +// with nothing granted contributes nothing at all, rather than widening either branch. +func TestProjectVisibilityPredicateSkipsOrgsWithoutGrants(t *testing.T) { + orgFull, orgEmpty, orgGranted := uuid.New(), uuid.New(), uuid.New() + granted := uuid.New() + + got := projectVisibilityPredicate( + []uuid.UUID{orgFull, orgEmpty, orgGranted}, + map[uuid.UUID][]uuid.UUID{orgEmpty: {}, orgGranted: {granted}}, + ) + require.NotNil(t, got) + query, args := renderProjectPredicate(t, got) + + assert.Contains(t, args, orgFull.String(), "the unrestricted org is matched") + assert.Contains(t, args, orgGranted.String(), "the org holding a grant is matched") + assert.NotContains(t, args, orgEmpty.String(), "an org with no grant is never named: %s", query) +} From d8235bd128067782aace24288213e6644312e64c Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 06:51:14 +0200 Subject: [PATCH 02/15] refactor(controlplane): express the referrer visibility filter directly Ent's HasWorkflowsWith renders this filter as `id IN (subquery)`, which describes a set to build rather than a condition to test, and the organization has to be reached through a nested relation predicate that Ent renders back into the same shape. Writing the correlated EXISTS by hand says what is meant: for this referrer, does a live workflow exist in one of these organizations. The references query also stops asking for DISTINCT. Ent adds one to every traversal because an edge query can multiply rows; this one cannot, since the join table is keyed on (referrer_id, referred_by_id) and the selected columns include the primary key. Neither of these is a speed-up, and neither is claimed as one. Both forms plan the same way and run within noise of each other at every node degree measured. They are here because they state the intent, and because the DISTINCT was doing nothing. The differential test added alongside requires the hand-written filter to return exactly what the generated one does. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez --- app/controlplane/pkg/data/referrer.go | 55 ++++++++++++++++------ app/controlplane/pkg/data/referrer_test.go | 29 ++++++++++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 9fa3e87f6..34a31dd23 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -23,7 +23,6 @@ import ( "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/project" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/projectversion" @@ -162,13 +161,8 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] predicateReferrer = append(predicateReferrer, referrer.Kind(*opts.RootKind)) } - // Prepare the workflow query predicate - predicateWF := []predicate.Workflow{ - workflow.DeletedAtIsNil(), workflow.HasOrganizationWith(organization.IDIn(orgIDs...)), - } - - // Attach the workflow predicate - predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...)) + // Attach the visibility predicate + predicateReferrer = append(predicateReferrer, referrerVisibleToOrgs(orgIDs)) // If a project filter is requested, attach it as a subquery predicate. An attestation root // matches only when its digest is one of the attestation_digests produced by a workflow run @@ -262,6 +256,38 @@ func (r *ReferrerRepo) projectScopePredicate(projectName, version string, orgIDs } } +// referrerVisibleToOrgs matches a referrer that is attached to at least one live workflow in the +// given organizations. +// +// It is written by hand rather than with referrer.HasWorkflowsWith because the generated form +// emits an uncorrelated `id IN (subquery)`: the planner materialises every referrer reachable by +// the caller's organizations and joins afterwards, which on a hub node means scanning the whole +// join table. Correlating the subquery to the referrer row turns it into a lookup per candidate. +// The organization is matched on the workflow's own column for the same reason the project filter +// does: a nested relation predicate here is flattened back into the uncorrelated shape. +func referrerVisibleToOrgs(orgIDs []uuid.UUID) predicate.Referrer { + orgs := make([]any, 0, len(orgIDs)) + for _, id := range orgIDs { + orgs = append(orgs, id) + } + + return func(s *sql.Selector) { + joinTable := sql.Table(referrer.WorkflowsTable).As("visible_rw") + workflows := sql.Table(referrer.WorkflowsInverseTable).As("visible_wf") + sub := sql.Dialect(s.Dialect()). + Select(joinTable.C(referrer.WorkflowsPrimaryKey[0])). + From(joinTable). + Join(workflows). + On(joinTable.C(referrer.WorkflowsPrimaryKey[1]), workflows.C(workflow.FieldID)). + Where(sql.And( + sql.ColumnsEQ(joinTable.C(referrer.WorkflowsPrimaryKey[0]), s.C(referrer.FieldID)), + sql.IsNull(workflows.C(workflow.FieldDeletedAt)), + sql.In(workflows.C(workflow.FieldOrganizationID), orgs...), + )) + s.Where(sql.Exists(sub)) + } +} + // projectVisibilityPredicate builds a project predicate that accepts a project iff it belongs to // one of the allowed orgs AND, when RBAC applies to that org, the project is in the caller's // visible set. Returns nil when no org grants any project visibility, so callers can treat that @@ -384,12 +410,8 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg // and by the visibility if needed predicateReferrer := []predicate.Referrer{} - predicateWF := []predicate.Workflow{ - workflow.DeletedAtIsNil(), workflow.HasOrganizationWith(organization.IDIn(allowedOrgs...)), - } - - // Attach the workflow predicate - predicateReferrer = append(predicateReferrer, referrer.HasWorkflowsWith(predicateWF...)) + // Attach the visibility predicate + predicateReferrer = append(predicateReferrer, referrerVisibleToOrgs(allowedOrgs)) // When scoping to a project, attestation references must belong to that project (optionally // narrowed to a version). Non-attestation references (materials/subjects) are kept as-is: @@ -411,6 +433,9 @@ 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(). + // referrer_references is keyed on (referrer_id, referred_by_id), so a reference cannot + // repeat and the DISTINCT Ent adds by default only buys a sort over the jsonb columns. + Unique(false). Order(referrer.ByCreatedAt(sql.OrderDesc())). Order(referrer.ByID(sql.OrderDesc())). Limit(p.Limit + 1) // fetch limit+1 to detect next page @@ -460,7 +485,7 @@ func (r *ReferrerRepo) doGet(ctx context.Context, root *ent.Referrer, allowedOrg Where( referrer.KindEQ(biz.ReferrerAttestationType), projectPred, - referrer.HasWorkflowsWith(predicateWF...), + referrerVisibleToOrgs(allowedOrgs), ). Exist(ctx) if err != nil { diff --git a/app/controlplane/pkg/data/referrer_test.go b/app/controlplane/pkg/data/referrer_test.go index 73b77ab0f..9f85640d5 100644 --- a/app/controlplane/pkg/data/referrer_test.go +++ b/app/controlplane/pkg/data/referrer_test.go @@ -23,6 +23,7 @@ import ( "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/project" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/referrer" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -170,3 +171,31 @@ func TestProjectVisibilityPredicateSkipsOrgsWithoutGrants(t *testing.T) { assert.Contains(t, args, orgGranted.String(), "the org holding a grant is matched") assert.NotContains(t, args, orgEmpty.String(), "an org with no grant is never named: %s", query) } + +// TestReferrerVisibleToOrgs pins the shape of the visibility filter. Ent's generated +// HasWorkflowsWith emits an uncorrelated `id IN (subquery)`, which makes the planner materialise +// every referrer in the caller's organizations before joining; correlating the subquery to the +// referrer row keeps it a lookup per candidate. +func TestReferrerVisibleToOrgs(t *testing.T) { + orgs := []uuid.UUID{uuid.New(), uuid.New(), uuid.New()} + + sel := sql.Dialect(dialect.Postgres).Select("id").From(sql.Table(referrer.Table)) + referrerVisibleToOrgs(orgs)(sel) + query, args := sel.Query() + + assert.Contains(t, query, "EXISTS", "the filter must be an EXISTS, not an IN over a materialised set: %s", query) + assert.NotContains(t, query, `"referrers"."id" IN (SELECT`, + "an uncorrelated IN is the shape this guards against: %s", query) + // Correlated to the outer referrer row. + assert.Contains(t, query, `"visible_rw"."referrer_id" = "referrers"."id"`, + "the subquery must be correlated to the referrer being filtered: %s", query) + // The organization is matched on the workflow's own column, not through a nested relation, + // which the planner would flatten back into the uncorrelated form. + assert.Contains(t, query, `"visible_wf"."organization_id" IN (`, + "organizations must be matched directly on the workflow column: %s", query) + assert.Contains(t, query, `"visible_wf"."deleted_at" IS NULL`, "deleted workflows never grant visibility") + // This predicate binds the UUIDs directly rather than as strings. + for _, o := range orgs { + assert.Contains(t, args, any(o), "every allowed organization is bound into the filter") + } +} From 3736299084debd3276389ff8b5b4ee3acd54abc9 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 06:55:07 +0200 Subject: [PATCH 03/15] test(controlplane): require the hand-written visibility filter to match the generated one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visibility filter was rewritten by hand because Ent's generated HasWorkflowsWith emits an uncorrelated IN. Hand-written SQL standing in for generated code needs more than a shape assertion, so this runs both forms against the same database and requires identical results. The fixture covers every state the filter can meet — a referrer with no workflow, with a live workflow in an allowed organization, in another organization, in a deleted workflow, in a deleted and a live one, in two live workflows of one organization, and in workflows of two organizations — crossed with one, several, duplicated, unknown and empty organization lists. It also pins the two query shapes the filter is used in, a plain lookup and a traversal through references, and that a referrer in several matching workflows comes back once, which is what makes dropping DISTINCT safe. It follows the pattern the repository already uses for database-backed tests: a testify suite on testhelpers.UseCasesEachTestSuite rather than its own container. The package's external test reaches the two filters through export_test.go, because the shared suite imports this package and so cannot be imported from inside it. Each assertion was checked against a deliberately broken filter: removing the deleted_at check, removing the correlation, joining on the wrong column of the through table, and correlating on the wrong side are all caught. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/data/export_test.go | 25 ++ .../referrer_visibility_integration_test.go | 239 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 app/controlplane/pkg/data/export_test.go create mode 100644 app/controlplane/pkg/data/referrer_visibility_integration_test.go diff --git a/app/controlplane/pkg/data/export_test.go b/app/controlplane/pkg/data/export_test.go new file mode 100644 index 000000000..7ba148563 --- /dev/null +++ b/app/controlplane/pkg/data/export_test.go @@ -0,0 +1,25 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package data + +// Internals exposed to the package's external tests. This file is only compiled during tests, so +// it does not widen the package's API. The visibility filters are reached from data_test because +// asserting on them against a real database needs the shared suite in biz/testhelpers, which +// imports this package and therefore cannot be imported from inside it. +var ( + ReferrerVisibleToOrgsForTest = referrerVisibleToOrgs + ProjectVisibilityPredicateForTest = projectVisibilityPredicate +) diff --git a/app/controlplane/pkg/data/referrer_visibility_integration_test.go b/app/controlplane/pkg/data/referrer_visibility_integration_test.go new file mode 100644 index 000000000..64ee84b51 --- /dev/null +++ b/app/controlplane/pkg/data/referrer_visibility_integration_test.go @@ -0,0 +1,239 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package data_test + +import ( + "context" + "fmt" + "sort" + "testing" + "time" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data" + "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/google/uuid" + "github.com/stretchr/testify/suite" +) + +// The visibility filter is written by hand because Ent's generated HasWorkflowsWith emits an +// uncorrelated IN. Hand-written SQL standing in for generated code needs more than a shape +// assertion, so these tests run both forms against the same data and require identical results +// for every state the filter can meet. +func TestReferrerVisibilityIntegration(t *testing.T) { + suite.Run(t, new(referrerVisibilityIntegrationTestSuite)) +} + +type referrerVisibilityIntegrationTestSuite struct { + testhelpers.UseCasesEachTestSuite + + // organizations + orgAllowed, orgAlsoAllowed, orgOther uuid.UUID + // referrers, named after the situation they are in + noWorkflow uuid.UUID + liveInAllowed uuid.UUID + liveInOther uuid.UUID + deletedInAllowed uuid.UUID + deletedAllowedLiveOther uuid.UUID + deletedAndLiveInAllowed uuid.UUID + twoLiveInAllowed uuid.UUID + liveInTwoAllowedOrgs uuid.UUID +} + +// generatedVisibilityPredicate is the Ent-generated filter the repository replaced. +func generatedVisibilityPredicate(orgIDs []uuid.UUID) predicate.Referrer { + return referrer.HasWorkflowsWith( + workflow.DeletedAtIsNil(), + workflow.HasOrganizationWith(organization.IDIn(orgIDs...)), + ) +} + +// SetupTest builds every combination of organization and workflow state the filter can encounter: +// no workflow, a live one, a deleted one, and mixtures across organizations. +func (s *referrerVisibilityIntegrationTestSuite) SetupTest() { + s.UseCasesEachTestSuite.SetupTest() + ctx := context.Background() + client := s.Data.DB + + org := func(name string) uuid.UUID { + return client.Organization.Create().SetName(name).SaveX(ctx).ID + } + s.orgAllowed, s.orgAlsoAllowed, s.orgOther = org("allowed"), org("also-allowed"), org("other") + + newWorkflow := func(name string, orgID uuid.UUID, deleted bool) uuid.UUID { + contract := client.WorkflowContract.Create().SetName(name + "-contract").SetOrganizationID(orgID).SaveX(ctx) + project := client.Project.Create().SetName(name + "-project").SetOrganizationID(orgID).SaveX(ctx) + b := client.Workflow.Create().SetName(name).SetOrganizationID(orgID). + SetProjectID(project.ID).SetContractID(contract.ID) + if deleted { + b = b.SetDeletedAt(time.Now()) + } + return b.SaveX(ctx).ID + } + + liveAllowed := newWorkflow("live-allowed", s.orgAllowed, false) + liveAllowed2 := newWorkflow("live-allowed-2", s.orgAllowed, false) + liveAlsoAllowed := newWorkflow("live-also-allowed", s.orgAlsoAllowed, false) + liveOther := newWorkflow("live-other", s.orgOther, false) + deletedAllowed := newWorkflow("deleted-allowed", s.orgAllowed, true) + + n := 0 + newReferrer := func(kind string, workflowIDs ...uuid.UUID) uuid.UUID { + n++ + return client.Referrer.Create(). + SetDigest(fmt.Sprintf("sha256:%064d", n)). + SetKind(kind). + SetDownloadable(true). + AddWorkflowIDs(workflowIDs...). + SaveX(ctx).ID + } + + s.noWorkflow = newReferrer("ATTESTATION") + s.liveInAllowed = newReferrer("ATTESTATION", liveAllowed) + s.liveInOther = newReferrer("ATTESTATION", liveOther) + s.deletedInAllowed = newReferrer("ATTESTATION", deletedAllowed) + s.deletedAllowedLiveOther = newReferrer("ATTESTATION", deletedAllowed, liveOther) + s.deletedAndLiveInAllowed = newReferrer("ATTESTATION", deletedAllowed, liveAllowed) + s.twoLiveInAllowed = newReferrer("SBOM_CYCLONEDX_JSON", liveAllowed, liveAllowed2) + s.liveInTwoAllowedOrgs = newReferrer("CONTAINER_IMAGE", liveAllowed, liveAlsoAllowed) + + // Give the root references so the traversal case has something to return. + client.Referrer.UpdateOneID(s.liveInAllowed). + AddReferenceIDs(s.twoLiveInAllowed, s.liveInOther, s.deletedInAllowed).ExecX(ctx) +} + +func (s *referrerVisibilityIntegrationTestSuite) TestMatchesTheGeneratedPredicate() { + ctx := context.Background() + + // Expectations are written out rather than derived, so a change in either form has to be + // justified here. + testCases := []struct { + name string + orgs []uuid.UUID + want []uuid.UUID + }{ + { + name: "one allowed org: live workflows in it, nothing else", + orgs: []uuid.UUID{s.orgAllowed}, + want: []uuid.UUID{s.liveInAllowed, s.deletedAndLiveInAllowed, s.twoLiveInAllowed, s.liveInTwoAllowedOrgs}, + }, + { + name: "the other org only", + orgs: []uuid.UUID{s.orgOther}, + want: []uuid.UUID{s.liveInOther, s.deletedAllowedLiveOther}, + }, + { + name: "two allowed orgs: the union, each referrer once", + orgs: []uuid.UUID{s.orgAllowed, s.orgAlsoAllowed}, + want: []uuid.UUID{s.liveInAllowed, s.deletedAndLiveInAllowed, s.twoLiveInAllowed, s.liveInTwoAllowedOrgs}, + }, + { + name: "all three orgs: everything with a live workflow", + orgs: []uuid.UUID{s.orgAllowed, s.orgAlsoAllowed, s.orgOther}, + want: []uuid.UUID{ + s.liveInAllowed, s.liveInOther, s.deletedAllowedLiveOther, + s.deletedAndLiveInAllowed, s.twoLiveInAllowed, s.liveInTwoAllowedOrgs, + }, + }, + { + name: "duplicated org ids must not duplicate results", + orgs: []uuid.UUID{s.orgAllowed, s.orgAllowed, s.orgAllowed}, + want: []uuid.UUID{s.liveInAllowed, s.deletedAndLiveInAllowed, s.twoLiveInAllowed, s.liveInTwoAllowedOrgs}, + }, + { + name: "an org with no workflows at all", + orgs: []uuid.UUID{uuid.New()}, + want: nil, + }, + { + name: "no orgs at all", + orgs: nil, + want: nil, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + generated := s.referrerIDs(ctx, generatedVisibilityPredicate(tc.orgs)) + handWritten := s.referrerIDs(ctx, data.ReferrerVisibleToOrgsForTest(tc.orgs)) + + s.Equal(sortIDs(tc.want), generated, "the generated filter no longer matches the documented expectation") + s.Equal(generated, handWritten, "the hand-written filter must select exactly what the generated one does") + }) + } + + // A referrer is never returned twice, which is what makes dropping Ent's DISTINCT safe: the + // filter is an EXISTS, so it cannot multiply rows the way a join would. + s.Run("a referrer in several matching workflows is returned once, without DISTINCT", func() { + orgs := []uuid.UUID{s.orgAllowed, s.orgAlsoAllowed, s.orgOther} + ids, err := s.Data.DB.Referrer.Query(). + Where(data.ReferrerVisibleToOrgsForTest(orgs)). + Unique(false). + IDs(ctx) + s.Require().NoError(err) + + counts := map[uuid.UUID]int{} + for _, id := range ids { + counts[id]++ + } + s.Equal(1, counts[s.twoLiveInAllowed], "a referrer in two workflows of one org appears once") + s.Equal(1, counts[s.liveInTwoAllowedOrgs], "a referrer in workflows of two orgs appears once") + }) + + // The referrer queries combine visibility with digest and kind filters, so the hand-written + // selector has to behave when it is not the only predicate. + s.Run("composes with other predicates", func() { + orgs := []uuid.UUID{s.orgAllowed} + generated := s.referrerIDs(ctx, generatedVisibilityPredicate(orgs), referrer.KindEQ("ATTESTATION")) + handWritten := s.referrerIDs(ctx, data.ReferrerVisibleToOrgsForTest(orgs), referrer.KindEQ("ATTESTATION")) + s.Equal(generated, handWritten) + s.NotEmpty(handWritten, "the fixture must exercise this, not pass by returning nothing") + }) + + // doGet applies the filter to root.QueryReferences(), a different query shape where the + // selector is already joined through the references table. + s.Run("behaves the same when traversing references", func() { + orgs := []uuid.UUID{s.orgAllowed, s.orgOther} + root := s.Data.DB.Referrer.GetX(ctx, s.liveInAllowed) + + generated, err := root.QueryReferences().Where(generatedVisibilityPredicate(orgs)).IDs(ctx) + s.Require().NoError(err) + handWritten, err := root.QueryReferences().Where(data.ReferrerVisibleToOrgsForTest(orgs)).Unique(false).IDs(ctx) + s.Require().NoError(err) + + s.Equal(sortIDs(generated), sortIDs(handWritten)) + s.NotEmpty(handWritten, "the fixture must exercise this, not pass by returning nothing") + }) +} + +func (s *referrerVisibilityIntegrationTestSuite) referrerIDs(ctx context.Context, preds ...predicate.Referrer) []uuid.UUID { + s.T().Helper() + ids, err := s.Data.DB.Referrer.Query().Where(preds...).IDs(ctx) + s.Require().NoError(err) + return sortIDs(ids) +} + +func sortIDs(ids []uuid.UUID) []uuid.UUID { + if len(ids) == 0 { + return nil + } + out := append([]uuid.UUID(nil), ids...) + sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) + return out +} From 90a033d009dbc0743fade26e93c1b74ca073fd92 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 07:13:49 +0200 Subject: [PATCH 04/15] feat(controlplane): add DiscoverEdges to answer how known referrers connect A client exploring the graph collects a set of referrers and then needs to know which of them are connected. Today the only way to learn that is to discover every one of them again, one request each, even though their identities are already in hand. DiscoverEdges answers the whole question in one request. It takes the referrers the caller holds, as digest and kind because a digest alone does not identify one, and returns the connections between them as pairs of indexes into the request. Referrers the caller cannot see, or that do not exist, contribute no edges rather than failing the call. Each connection comes back once, and the request is capped at a thousand referrers. The work is bounded by how many referrers were asked about, not by how many references any of them has: a shared material can be referenced by a hundred thousand attestations and asking about a hundred nodes must not walk that. The store is therefore read from the attestation side of each connection, whose degree is the number of materials in one attestation. That is complete because a connection only ever exists between an attestation and one of its materials or subjects, and the attestation's own row is always written. Ingest writes the material's row pointing back as well, but this does not rely on it: older data holds many connections in the attestation direction only. Visibility is resolved through the same organization, project and role filters as DiscoverPrivate, and the endpoint requires the same read policy. On a local dataset of 79,605 attestations it returns exactly the edge set a full traversal finds, verified across eight roots including the two highest-degree nodes: no edge missing, none extra. It answers in 7 to 13 ms for the 13 to 105 node sets those traversals produce, and 38 ms at the thousand-node cap. Also corrects the comment on the Unique(false) added earlier: what makes dropping the DISTINCT safe is that the selected columns include the primary key and no predicate there introduces a join, which is a wider and more durable statement than the join table's own key. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- .../api/controlplane/v1/referrer.pb.go | 320 +++++++++++++-- .../api/controlplane/v1/referrer.proto | 83 ++++ .../api/controlplane/v1/referrer_grpc.pb.go | 39 ++ .../api/controlplane/v1/referrer_http.pb.go | 40 ++ .../gen/frontend/controlplane/v1/referrer.ts | 373 ++++++++++++++++++ ...ntrolplane.v1.ReferrerEdge.jsonschema.json | 22 ++ .../controlplane.v1.ReferrerEdge.schema.json | 22 ++ ...ontrolplane.v1.ReferrerRef.jsonschema.json | 20 + .../controlplane.v1.ReferrerRef.schema.json | 20 + ...erviceDiscoverEdgesRequest.jsonschema.json | 35 ++ ...rerServiceDiscoverEdgesRequest.schema.json | 35 ++ ...rviceDiscoverEdgesResponse.jsonschema.json | 17 + ...erServiceDiscoverEdgesResponse.schema.json | 17 + app/controlplane/api/gen/openapi/openapi.yaml | 100 ++++- app/controlplane/internal/service/referrer.go | 53 +++ app/controlplane/pkg/authz/authz.go | 1 + app/controlplane/pkg/biz/referrer.go | 59 +++ .../pkg/biz/referrer_integration_test.go | 140 +++++++ app/controlplane/pkg/data/referrer.go | 176 ++++++++- 19 files changed, 1533 insertions(+), 39 deletions(-) create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json create mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 160ea580b..a2fe95f6e 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -40,6 +40,228 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method +type ReferrerServiceDiscoverEdgesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The referrers to find connections between. A digest alone does not identify a referrer, so + // the kind travels with it. + Nodes []*ReferrerRef `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` + // Restricts the answer to a project, the same way DiscoverPrivate does + ProjectName string `protobuf:"bytes,2,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` + // Only meaningful alongside project_name: version names are unique within a project + ProjectVersion string `protobuf:"bytes,3,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReferrerServiceDiscoverEdgesRequest) Reset() { + *x = ReferrerServiceDiscoverEdgesRequest{} + mi := &file_controlplane_v1_referrer_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReferrerServiceDiscoverEdgesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReferrerServiceDiscoverEdgesRequest) ProtoMessage() {} + +func (x *ReferrerServiceDiscoverEdgesRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_referrer_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReferrerServiceDiscoverEdgesRequest.ProtoReflect.Descriptor instead. +func (*ReferrerServiceDiscoverEdgesRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{0} +} + +func (x *ReferrerServiceDiscoverEdgesRequest) GetNodes() []*ReferrerRef { + if x != nil { + return x.Nodes + } + return nil +} + +func (x *ReferrerServiceDiscoverEdgesRequest) GetProjectName() string { + if x != nil { + return x.ProjectName + } + return "" +} + +func (x *ReferrerServiceDiscoverEdgesRequest) GetProjectVersion() string { + if x != nil { + return x.ProjectVersion + } + return "" +} + +// ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method +type ReferrerServiceDiscoverEdgesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Each connection once, regardless of the direction it is stored in + Edges []*ReferrerEdge `protobuf:"bytes,1,rep,name=edges,proto3" json:"edges,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReferrerServiceDiscoverEdgesResponse) Reset() { + *x = ReferrerServiceDiscoverEdgesResponse{} + mi := &file_controlplane_v1_referrer_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReferrerServiceDiscoverEdgesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReferrerServiceDiscoverEdgesResponse) ProtoMessage() {} + +func (x *ReferrerServiceDiscoverEdgesResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_referrer_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReferrerServiceDiscoverEdgesResponse.ProtoReflect.Descriptor instead. +func (*ReferrerServiceDiscoverEdgesResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{1} +} + +func (x *ReferrerServiceDiscoverEdgesResponse) GetEdges() []*ReferrerEdge { + if x != nil { + return x.Edges + } + return nil +} + +// ReferrerRef identifies a referrer +type ReferrerRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead + Digest string `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` + // Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReferrerRef) Reset() { + *x = ReferrerRef{} + mi := &file_controlplane_v1_referrer_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReferrerRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReferrerRef) ProtoMessage() {} + +func (x *ReferrerRef) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_referrer_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReferrerRef.ProtoReflect.Descriptor instead. +func (*ReferrerRef) Descriptor() ([]byte, []int) { + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{2} +} + +func (x *ReferrerRef) GetDigest() string { + if x != nil { + return x.Digest + } + return "" +} + +func (x *ReferrerRef) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +// ReferrerEdge is a connection between two of the requested referrers, given as indexes into the +// request's nodes so the digests do not travel back +type ReferrerEdge struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Index of the lower end of the connection, always smaller than to + From uint32 `protobuf:"varint,1,opt,name=from,proto3" json:"from,omitempty"` + // Index of the higher end of the connection + To uint32 `protobuf:"varint,2,opt,name=to,proto3" json:"to,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReferrerEdge) Reset() { + *x = ReferrerEdge{} + mi := &file_controlplane_v1_referrer_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReferrerEdge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReferrerEdge) ProtoMessage() {} + +func (x *ReferrerEdge) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_referrer_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReferrerEdge.ProtoReflect.Descriptor instead. +func (*ReferrerEdge) Descriptor() ([]byte, []int) { + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{3} +} + +func (x *ReferrerEdge) GetFrom() uint32 { + if x != nil { + return x.From + } + return 0 +} + +func (x *ReferrerEdge) GetTo() uint32 { + if x != nil { + return x.To + } + return 0 +} + // ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method type ReferrerServiceDiscoverPrivateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -62,7 +284,7 @@ type ReferrerServiceDiscoverPrivateRequest struct { func (x *ReferrerServiceDiscoverPrivateRequest) Reset() { *x = ReferrerServiceDiscoverPrivateRequest{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[0] + mi := &file_controlplane_v1_referrer_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -74,7 +296,7 @@ func (x *ReferrerServiceDiscoverPrivateRequest) String() string { func (*ReferrerServiceDiscoverPrivateRequest) ProtoMessage() {} func (x *ReferrerServiceDiscoverPrivateRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[0] + mi := &file_controlplane_v1_referrer_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -87,7 +309,7 @@ func (x *ReferrerServiceDiscoverPrivateRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ReferrerServiceDiscoverPrivateRequest.ProtoReflect.Descriptor instead. func (*ReferrerServiceDiscoverPrivateRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{0} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{4} } func (x *ReferrerServiceDiscoverPrivateRequest) GetDigest() string { @@ -138,7 +360,7 @@ type ReferrerServiceDiscoverPrivateResponse struct { func (x *ReferrerServiceDiscoverPrivateResponse) Reset() { *x = ReferrerServiceDiscoverPrivateResponse{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[1] + mi := &file_controlplane_v1_referrer_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -150,7 +372,7 @@ func (x *ReferrerServiceDiscoverPrivateResponse) String() string { func (*ReferrerServiceDiscoverPrivateResponse) ProtoMessage() {} func (x *ReferrerServiceDiscoverPrivateResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[1] + mi := &file_controlplane_v1_referrer_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -163,7 +385,7 @@ func (x *ReferrerServiceDiscoverPrivateResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ReferrerServiceDiscoverPrivateResponse.ProtoReflect.Descriptor instead. func (*ReferrerServiceDiscoverPrivateResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{1} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{5} } func (x *ReferrerServiceDiscoverPrivateResponse) GetResult() *ReferrerItem { @@ -203,7 +425,7 @@ type ReferrerItem struct { func (x *ReferrerItem) Reset() { *x = ReferrerItem{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[2] + mi := &file_controlplane_v1_referrer_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -215,7 +437,7 @@ func (x *ReferrerItem) String() string { func (*ReferrerItem) ProtoMessage() {} func (x *ReferrerItem) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[2] + mi := &file_controlplane_v1_referrer_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -228,7 +450,7 @@ func (x *ReferrerItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferrerItem.ProtoReflect.Descriptor instead. func (*ReferrerItem) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{2} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{6} } func (x *ReferrerItem) GetDigest() string { @@ -284,7 +506,24 @@ var File_controlplane_v1_referrer_proto protoreflect.FileDescriptor const file_controlplane_v1_referrer_proto_rawDesc = "" + "\n" + - "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xf0\x03\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\"\xc6\x03\n" + + "#ReferrerServiceDiscoverEdgesRequest\x12?\n" + + "\x05nodes\x18\x01 \x03(\v2\x1c.controlplane.v1.ReferrerRefB\v\xbaH\b\x92\x01\x05\b\x02\x10\xe8\aR\x05nodes\x12!\n" + + "\fproject_name\x18\x02 \x01(\tR\vprojectName\x12'\n" + + "\x0fproject_version\x18\x03 \x01(\tR\x0eprojectVersion:\x91\x02\x92Aa\n" + + "_*#ReferrerServiceDiscoverEdgesRequest28Request to discover the edges between a set of referrers\xbaH\xa9\x01\x1a\xa6\x01\n" + + "4discover_edges_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\xaf\x01\n" + + "$ReferrerServiceDiscoverEdgesResponse\x123\n" + + "\x05edges\x18\x01 \x03(\v2\x1d.controlplane.v1.ReferrerEdgeR\x05edges:R\x92AO\n" + + "M*$ReferrerServiceDiscoverEdgesResponse2%Response for the DiscoverEdges method\"\x8d\x01\n" + + "\vReferrerRef\x12\x1f\n" + + "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x1b\n" + + "\x04kind\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04kind:@\x92A=\n" + + ";*\vReferrerRef2,A reference to a referrer by digest and kind\"\x88\x01\n" + + "\fReferrerEdge\x12\x12\n" + + "\x04from\x18\x01 \x01(\rR\x04from\x12\x0e\n" + + "\x02to\x18\x02 \x01(\rR\x02to:T\x92AQ\n" + + "O*\fReferrerEdge2?A connection between two referrers, as indexes into the request\"\xf0\x03\n" + "%ReferrerServiceDiscoverPrivateRequest\x12\x1f\n" + "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12H\n" + @@ -318,9 +557,10 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01:B\x92A?\n" + - "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\x90\x03\n" + + "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\xa5\x06\n" + "\x0fReferrerService\x12\xa9\x02\n" + - "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\xa4\x01\x92A\x86\x01\x12\x19Discover private referrer\x1aWReturns the referrer item for a given digest in the organizations of the logged-in user:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x1aQ\x92AN\n" + + "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\xa4\x01\x92A\x86\x01\x12\x19Discover private referrer\x1aWReturns the referrer item for a given digest in the organizations of the logged-in user:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x12\x92\x03\n" + + "\rDiscoverEdges\x124.controlplane.v1.ReferrerServiceDiscoverEdgesRequest\x1a5.controlplane.v1.ReferrerServiceDiscoverEdgesResponse\"\x93\x02\x92A\xf5\x01\x12*Discover the edges between known referrers\x1a\xb4\x01Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges.:\x10application/json\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/discover/edges\x1aQ\x92AN\n" + "\x0fReferrerService\x12;Referrer service for discovering referred content by digestBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" var ( @@ -335,32 +575,40 @@ func file_controlplane_v1_referrer_proto_rawDescGZIP() []byte { return file_controlplane_v1_referrer_proto_rawDescData } -var file_controlplane_v1_referrer_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_controlplane_v1_referrer_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_controlplane_v1_referrer_proto_goTypes = []any{ - (*ReferrerServiceDiscoverPrivateRequest)(nil), // 0: controlplane.v1.ReferrerServiceDiscoverPrivateRequest - (*ReferrerServiceDiscoverPrivateResponse)(nil), // 1: controlplane.v1.ReferrerServiceDiscoverPrivateResponse - (*ReferrerItem)(nil), // 2: controlplane.v1.ReferrerItem - nil, // 3: controlplane.v1.ReferrerItem.MetadataEntry - nil, // 4: controlplane.v1.ReferrerItem.AnnotationsEntry - (*CursorPaginationRequest)(nil), // 5: controlplane.v1.CursorPaginationRequest - (*CursorPaginationResponse)(nil), // 6: controlplane.v1.CursorPaginationResponse - (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp + (*ReferrerServiceDiscoverEdgesRequest)(nil), // 0: controlplane.v1.ReferrerServiceDiscoverEdgesRequest + (*ReferrerServiceDiscoverEdgesResponse)(nil), // 1: controlplane.v1.ReferrerServiceDiscoverEdgesResponse + (*ReferrerRef)(nil), // 2: controlplane.v1.ReferrerRef + (*ReferrerEdge)(nil), // 3: controlplane.v1.ReferrerEdge + (*ReferrerServiceDiscoverPrivateRequest)(nil), // 4: controlplane.v1.ReferrerServiceDiscoverPrivateRequest + (*ReferrerServiceDiscoverPrivateResponse)(nil), // 5: controlplane.v1.ReferrerServiceDiscoverPrivateResponse + (*ReferrerItem)(nil), // 6: controlplane.v1.ReferrerItem + nil, // 7: controlplane.v1.ReferrerItem.MetadataEntry + nil, // 8: controlplane.v1.ReferrerItem.AnnotationsEntry + (*CursorPaginationRequest)(nil), // 9: controlplane.v1.CursorPaginationRequest + (*CursorPaginationResponse)(nil), // 10: controlplane.v1.CursorPaginationResponse + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp } var file_controlplane_v1_referrer_proto_depIdxs = []int32{ - 5, // 0: controlplane.v1.ReferrerServiceDiscoverPrivateRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest - 2, // 1: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem - 6, // 2: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse - 2, // 3: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem - 7, // 4: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp - 3, // 5: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry - 4, // 6: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry - 0, // 7: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest - 1, // 8: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse - 8, // [8:9] is the sub-list for method output_type - 7, // [7:8] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 2, // 0: controlplane.v1.ReferrerServiceDiscoverEdgesRequest.nodes:type_name -> controlplane.v1.ReferrerRef + 3, // 1: controlplane.v1.ReferrerServiceDiscoverEdgesResponse.edges:type_name -> controlplane.v1.ReferrerEdge + 9, // 2: controlplane.v1.ReferrerServiceDiscoverPrivateRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest + 6, // 3: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem + 10, // 4: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse + 6, // 5: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem + 11, // 6: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp + 7, // 7: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry + 8, // 8: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry + 4, // 9: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest + 0, // 10: controlplane.v1.ReferrerService.DiscoverEdges:input_type -> controlplane.v1.ReferrerServiceDiscoverEdgesRequest + 5, // 11: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse + 1, // 12: controlplane.v1.ReferrerService.DiscoverEdges:output_type -> controlplane.v1.ReferrerServiceDiscoverEdgesResponse + 11, // [11:13] is the sub-list for method output_type + 9, // [9:11] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_controlplane_v1_referrer_proto_init() } @@ -375,7 +623,7 @@ func file_controlplane_v1_referrer_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_referrer_proto_rawDesc), len(file_controlplane_v1_referrer_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index ce5be09f9..ab1eb4e6f 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -35,12 +35,95 @@ service ReferrerService { produces: ["application/json"] }; } + // DiscoverEdges returns the connections between a set of referrers the caller already holds + rpc DiscoverEdges(ReferrerServiceDiscoverEdgesRequest) returns (ReferrerServiceDiscoverEdgesResponse) { + option (google.api.http) = { + post: "/discover/edges" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Discover the edges between known referrers" + description: "Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges." + produces: ["application/json"] + }; + } option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_tag) = { name: "ReferrerService" description: "Referrer service for discovering referred content by digest" }; } +// ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method +message ReferrerServiceDiscoverEdgesRequest { + // The referrers to find connections between. A digest alone does not identify a referrer, so + // the kind travels with it. + repeated ReferrerRef nodes = 1 [(buf.validate.field).repeated = { + min_items: 2 + max_items: 1000 + }]; + // Restricts the answer to a project, the same way DiscoverPrivate does + string project_name = 2; + // Only meaningful alongside project_name: version names are unique within a project + string project_version = 3; + + option (buf.validate.message).cel = { + id: "discover_edges_project_version_requires_project_name" + expression: "!(this.project_version != '' && this.project_name == '')" + message: "project_name must be set when project_version is set" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "ReferrerServiceDiscoverEdgesRequest" + description: "Request to discover the edges between a set of referrers" + } + }; +} + +// ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method +message ReferrerServiceDiscoverEdgesResponse { + // Each connection once, regardless of the direction it is stored in + repeated ReferrerEdge edges = 1; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "ReferrerServiceDiscoverEdgesResponse" + description: "Response for the DiscoverEdges method" + } + }; +} + +// ReferrerRef identifies a referrer +message ReferrerRef { + // Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead + string digest = 1 [(buf.validate.field).string = {min_len: 1}]; + // Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... + string kind = 2 [(buf.validate.field).string = {min_len: 1}]; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "ReferrerRef" + description: "A reference to a referrer by digest and kind" + } + }; +} + +// ReferrerEdge is a connection between two of the requested referrers, given as indexes into the +// request's nodes so the digests do not travel back +message ReferrerEdge { + // Index of the lower end of the connection, always smaller than to + uint32 from = 1; + // Index of the higher end of the connection + uint32 to = 2; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "ReferrerEdge" + description: "A connection between two referrers, as indexes into the request" + } + }; +} + // ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method message ReferrerServiceDiscoverPrivateRequest { // Digest is the unique identifier of the referrer to discover diff --git a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go index 62856be96..00dc2e343 100644 --- a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go @@ -35,6 +35,7 @@ const _ = grpc.SupportPackageIsVersion7 const ( ReferrerService_DiscoverPrivate_FullMethodName = "/controlplane.v1.ReferrerService/DiscoverPrivate" + ReferrerService_DiscoverEdges_FullMethodName = "/controlplane.v1.ReferrerService/DiscoverEdges" ) // ReferrerServiceClient is the client API for ReferrerService service. @@ -43,6 +44,8 @@ const ( type ReferrerServiceClient interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) + // DiscoverEdges returns the connections between a set of referrers the caller already holds + DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) } type referrerServiceClient struct { @@ -62,12 +65,23 @@ func (c *referrerServiceClient) DiscoverPrivate(ctx context.Context, in *Referre return out, nil } +func (c *referrerServiceClient) DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) { + out := new(ReferrerServiceDiscoverEdgesResponse) + err := c.cc.Invoke(ctx, ReferrerService_DiscoverEdges_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // ReferrerServiceServer is the server API for ReferrerService service. // All implementations must embed UnimplementedReferrerServiceServer // for forward compatibility type ReferrerServiceServer interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) + // DiscoverEdges returns the connections between a set of referrers the caller already holds + DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) mustEmbedUnimplementedReferrerServiceServer() } @@ -78,6 +92,9 @@ type UnimplementedReferrerServiceServer struct { func (UnimplementedReferrerServiceServer) DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method DiscoverPrivate not implemented") } +func (UnimplementedReferrerServiceServer) DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DiscoverEdges not implemented") +} func (UnimplementedReferrerServiceServer) mustEmbedUnimplementedReferrerServiceServer() {} // UnsafeReferrerServiceServer may be embedded to opt out of forward compatibility for this service. @@ -109,6 +126,24 @@ func _ReferrerService_DiscoverPrivate_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _ReferrerService_DiscoverEdges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReferrerServiceDiscoverEdgesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ReferrerServiceServer).DiscoverEdges(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ReferrerService_DiscoverEdges_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ReferrerServiceServer).DiscoverEdges(ctx, req.(*ReferrerServiceDiscoverEdgesRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ReferrerService_ServiceDesc is the grpc.ServiceDesc for ReferrerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -120,6 +155,10 @@ var ReferrerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DiscoverPrivate", Handler: _ReferrerService_DiscoverPrivate_Handler, }, + { + MethodName: "DiscoverEdges", + Handler: _ReferrerService_DiscoverEdges_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "controlplane/v1/referrer.proto", diff --git a/app/controlplane/api/controlplane/v1/referrer_http.pb.go b/app/controlplane/api/controlplane/v1/referrer_http.pb.go index 24f71246a..6a1ff86f7 100644 --- a/app/controlplane/api/controlplane/v1/referrer_http.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_http.pb.go @@ -19,9 +19,12 @@ var _ = binding.EncodeURL const _ = http.SupportPackageIsVersion1 +const OperationReferrerServiceDiscoverEdges = "/controlplane.v1.ReferrerService/DiscoverEdges" const OperationReferrerServiceDiscoverPrivate = "/controlplane.v1.ReferrerService/DiscoverPrivate" type ReferrerServiceHTTPServer interface { + // DiscoverEdges DiscoverEdges returns the connections between a set of referrers the caller already holds + DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) // DiscoverPrivate DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) } @@ -29,6 +32,7 @@ type ReferrerServiceHTTPServer interface { func RegisterReferrerServiceHTTPServer(s *http.Server, srv ReferrerServiceHTTPServer) { r := s.Route("/") r.GET("/discover/{digest}", _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv)) + r.POST("/discover/edges", _ReferrerService_DiscoverEdges0_HTTP_Handler(srv)) } func _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv ReferrerServiceHTTPServer) func(ctx http.Context) error { @@ -53,7 +57,30 @@ func _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv ReferrerServiceHTTPServe } } +func _ReferrerService_DiscoverEdges0_HTTP_Handler(srv ReferrerServiceHTTPServer) func(ctx http.Context) error { + return func(ctx http.Context) error { + var in ReferrerServiceDiscoverEdgesRequest + if err := ctx.Bind(&in); err != nil { + return err + } + if err := ctx.BindQuery(&in); err != nil { + return err + } + http.SetOperation(ctx, OperationReferrerServiceDiscoverEdges) + h := ctx.Middleware(func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.DiscoverEdges(ctx, req.(*ReferrerServiceDiscoverEdgesRequest)) + }) + out, err := h(ctx, &in) + if err != nil { + return err + } + reply := out.(*ReferrerServiceDiscoverEdgesResponse) + return ctx.Result(200, reply) + } +} + type ReferrerServiceHTTPClient interface { + DiscoverEdges(ctx context.Context, req *ReferrerServiceDiscoverEdgesRequest, opts ...http.CallOption) (rsp *ReferrerServiceDiscoverEdgesResponse, err error) DiscoverPrivate(ctx context.Context, req *ReferrerServiceDiscoverPrivateRequest, opts ...http.CallOption) (rsp *ReferrerServiceDiscoverPrivateResponse, err error) } @@ -65,6 +92,19 @@ func NewReferrerServiceHTTPClient(client *http.Client) ReferrerServiceHTTPClient return &ReferrerServiceHTTPClientImpl{client} } +func (c *ReferrerServiceHTTPClientImpl) DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...http.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) { + var out ReferrerServiceDiscoverEdgesResponse + pattern := "/discover/edges" + path := binding.EncodeURL(pattern, in, false) + opts = append(opts, http.Operation(OperationReferrerServiceDiscoverEdges)) + opts = append(opts, http.PathTemplate(pattern)) + err := c.cc.Invoke(ctx, "POST", path, in, &out, opts...) + if err != nil { + return nil, err + } + return &out, err +} + func (c *ReferrerServiceHTTPClientImpl) DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...http.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) { var out ReferrerServiceDiscoverPrivateResponse pattern := "/discover/{digest}" diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 270c7e357..73d6f737f 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -7,6 +7,44 @@ import { CursorPaginationRequest, CursorPaginationResponse } from "./pagination" export const protobufPackage = "controlplane.v1"; +/** ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method */ +export interface ReferrerServiceDiscoverEdgesRequest { + /** + * The referrers to find connections between. A digest alone does not identify a referrer, so + * the kind travels with it. + */ + nodes: ReferrerRef[]; + /** Restricts the answer to a project, the same way DiscoverPrivate does */ + projectName: string; + /** Only meaningful alongside project_name: version names are unique within a project */ + projectVersion: string; +} + +/** ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method */ +export interface ReferrerServiceDiscoverEdgesResponse { + /** Each connection once, regardless of the direction it is stored in */ + edges: ReferrerEdge[]; +} + +/** ReferrerRef identifies a referrer */ +export interface ReferrerRef { + /** Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead */ + digest: string; + /** Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... */ + kind: string; +} + +/** + * ReferrerEdge is a connection between two of the requested referrers, given as indexes into the + * request's nodes so the digests do not travel back + */ +export interface ReferrerEdge { + /** Index of the lower end of the connection, always smaller than to */ + from: number; + /** Index of the higher end of the connection */ + to: number; +} + /** ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method */ export interface ReferrerServiceDiscoverPrivateRequest { /** Digest is the unique identifier of the referrer to discover */ @@ -66,6 +104,301 @@ export interface ReferrerItem_AnnotationsEntry { value: string; } +function createBaseReferrerServiceDiscoverEdgesRequest(): ReferrerServiceDiscoverEdgesRequest { + return { nodes: [], projectName: "", projectVersion: "" }; +} + +export const ReferrerServiceDiscoverEdgesRequest = { + encode(message: ReferrerServiceDiscoverEdgesRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + for (const v of message.nodes) { + ReferrerRef.encode(v!, writer.uint32(10).fork()).ldelim(); + } + if (message.projectName !== "") { + writer.uint32(18).string(message.projectName); + } + if (message.projectVersion !== "") { + writer.uint32(26).string(message.projectVersion); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerServiceDiscoverEdgesRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseReferrerServiceDiscoverEdgesRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.nodes.push(ReferrerRef.decode(reader, reader.uint32())); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.projectName = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.projectVersion = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ReferrerServiceDiscoverEdgesRequest { + return { + nodes: Array.isArray(object?.nodes) ? object.nodes.map((e: any) => ReferrerRef.fromJSON(e)) : [], + projectName: isSet(object.projectName) ? String(object.projectName) : "", + projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", + }; + }, + + toJSON(message: ReferrerServiceDiscoverEdgesRequest): unknown { + const obj: any = {}; + if (message.nodes) { + obj.nodes = message.nodes.map((e) => e ? ReferrerRef.toJSON(e) : undefined); + } else { + obj.nodes = []; + } + message.projectName !== undefined && (obj.projectName = message.projectName); + message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); + return obj; + }, + + create, I>>( + base?: I, + ): ReferrerServiceDiscoverEdgesRequest { + return ReferrerServiceDiscoverEdgesRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): ReferrerServiceDiscoverEdgesRequest { + const message = createBaseReferrerServiceDiscoverEdgesRequest(); + message.nodes = object.nodes?.map((e) => ReferrerRef.fromPartial(e)) || []; + message.projectName = object.projectName ?? ""; + message.projectVersion = object.projectVersion ?? ""; + return message; + }, +}; + +function createBaseReferrerServiceDiscoverEdgesResponse(): ReferrerServiceDiscoverEdgesResponse { + return { edges: [] }; +} + +export const ReferrerServiceDiscoverEdgesResponse = { + encode(message: ReferrerServiceDiscoverEdgesResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + for (const v of message.edges) { + ReferrerEdge.encode(v!, writer.uint32(10).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerServiceDiscoverEdgesResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseReferrerServiceDiscoverEdgesResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.edges.push(ReferrerEdge.decode(reader, reader.uint32())); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ReferrerServiceDiscoverEdgesResponse { + return { edges: Array.isArray(object?.edges) ? object.edges.map((e: any) => ReferrerEdge.fromJSON(e)) : [] }; + }, + + toJSON(message: ReferrerServiceDiscoverEdgesResponse): unknown { + const obj: any = {}; + if (message.edges) { + obj.edges = message.edges.map((e) => e ? ReferrerEdge.toJSON(e) : undefined); + } else { + obj.edges = []; + } + return obj; + }, + + create, I>>( + base?: I, + ): ReferrerServiceDiscoverEdgesResponse { + return ReferrerServiceDiscoverEdgesResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): ReferrerServiceDiscoverEdgesResponse { + const message = createBaseReferrerServiceDiscoverEdgesResponse(); + message.edges = object.edges?.map((e) => ReferrerEdge.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseReferrerRef(): ReferrerRef { + return { digest: "", kind: "" }; +} + +export const ReferrerRef = { + encode(message: ReferrerRef, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.digest !== "") { + writer.uint32(10).string(message.digest); + } + if (message.kind !== "") { + writer.uint32(18).string(message.kind); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerRef { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseReferrerRef(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.digest = reader.string(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.kind = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ReferrerRef { + return { + digest: isSet(object.digest) ? String(object.digest) : "", + kind: isSet(object.kind) ? String(object.kind) : "", + }; + }, + + toJSON(message: ReferrerRef): unknown { + const obj: any = {}; + message.digest !== undefined && (obj.digest = message.digest); + message.kind !== undefined && (obj.kind = message.kind); + return obj; + }, + + create, I>>(base?: I): ReferrerRef { + return ReferrerRef.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ReferrerRef { + const message = createBaseReferrerRef(); + message.digest = object.digest ?? ""; + message.kind = object.kind ?? ""; + return message; + }, +}; + +function createBaseReferrerEdge(): ReferrerEdge { + return { from: 0, to: 0 }; +} + +export const ReferrerEdge = { + encode(message: ReferrerEdge, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.from !== 0) { + writer.uint32(8).uint32(message.from); + } + if (message.to !== 0) { + writer.uint32(16).uint32(message.to); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerEdge { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseReferrerEdge(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.from = reader.uint32(); + continue; + case 2: + if (tag !== 16) { + break; + } + + message.to = reader.uint32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ReferrerEdge { + return { from: isSet(object.from) ? Number(object.from) : 0, to: isSet(object.to) ? Number(object.to) : 0 }; + }, + + toJSON(message: ReferrerEdge): unknown { + const obj: any = {}; + message.from !== undefined && (obj.from = Math.round(message.from)); + message.to !== undefined && (obj.to = Math.round(message.to)); + return obj; + }, + + create, I>>(base?: I): ReferrerEdge { + return ReferrerEdge.fromPartial(base ?? {}); + }, + + fromPartial, I>>(object: I): ReferrerEdge { + const message = createBaseReferrerEdge(); + message.from = object.from ?? 0; + message.to = object.to ?? 0; + return message; + }, +}; + function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "" }; } @@ -594,6 +927,11 @@ export interface ReferrerService { request: DeepPartial, metadata?: grpc.Metadata, ): Promise; + /** DiscoverEdges returns the connections between a set of referrers the caller already holds */ + DiscoverEdges( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise; } export class ReferrerServiceClientImpl implements ReferrerService { @@ -602,6 +940,7 @@ export class ReferrerServiceClientImpl implements ReferrerService { constructor(rpc: Rpc) { this.rpc = rpc; this.DiscoverPrivate = this.DiscoverPrivate.bind(this); + this.DiscoverEdges = this.DiscoverEdges.bind(this); } DiscoverPrivate( @@ -614,6 +953,17 @@ export class ReferrerServiceClientImpl implements ReferrerService { metadata, ); } + + DiscoverEdges( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise { + return this.rpc.unary( + ReferrerServiceDiscoverEdgesDesc, + ReferrerServiceDiscoverEdgesRequest.fromPartial(request), + metadata, + ); + } } export const ReferrerServiceDesc = { serviceName: "controlplane.v1.ReferrerService" }; @@ -641,6 +991,29 @@ export const ReferrerServiceDiscoverPrivateDesc: UnaryMethodDefinitionish = { } as any, }; +export const ReferrerServiceDiscoverEdgesDesc: UnaryMethodDefinitionish = { + methodName: "DiscoverEdges", + service: ReferrerServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return ReferrerServiceDiscoverEdgesRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = ReferrerServiceDiscoverEdgesResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + interface UnaryMethodDefinitionishR extends grpc.UnaryMethodDefinition { requestStream: any; responseStream: any; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json new file mode 100644 index 000000000..621ffbc19 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json @@ -0,0 +1,22 @@ +{ + "$id": "controlplane.v1.ReferrerEdge.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerEdge is a connection between two of the requested referrers, given as indexes into the\n request's nodes so the digests do not travel back", + "properties": { + "from": { + "description": "Index of the lower end of the connection, always smaller than to", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "to": { + "description": "Index of the higher end of the connection", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "title": "Referrer Edge", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json new file mode 100644 index 000000000..c930d7463 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json @@ -0,0 +1,22 @@ +{ + "$id": "controlplane.v1.ReferrerEdge.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerEdge is a connection between two of the requested referrers, given as indexes into the\n request's nodes so the digests do not travel back", + "properties": { + "from": { + "description": "Index of the lower end of the connection, always smaller than to", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "to": { + "description": "Index of the higher end of the connection", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "title": "Referrer Edge", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json new file mode 100644 index 000000000..7cd9c4a2c --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json @@ -0,0 +1,20 @@ +{ + "$id": "controlplane.v1.ReferrerRef.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerRef identifies a referrer", + "properties": { + "digest": { + "description": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ...", + "minLength": 1, + "type": "string" + } + }, + "title": "Referrer Ref", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json new file mode 100644 index 000000000..cfe3200f2 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json @@ -0,0 +1,20 @@ +{ + "$id": "controlplane.v1.ReferrerRef.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerRef identifies a referrer", + "properties": { + "digest": { + "description": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ...", + "minLength": 1, + "type": "string" + } + }, + "title": "Referrer Ref", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json new file mode 100644 index 000000000..2f4b7dc75 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json @@ -0,0 +1,35 @@ +{ + "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method", + "patternProperties": { + "^(project_name)$": { + "description": "Restricts the answer to a project, the same way DiscoverPrivate does", + "type": "string" + }, + "^(project_version)$": { + "description": "Only meaningful alongside project_name: version names are unique within a project", + "type": "string" + } + }, + "properties": { + "nodes": { + "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.", + "items": { + "$ref": "controlplane.v1.ReferrerRef.jsonschema.json" + }, + "type": "array" + }, + "projectName": { + "description": "Restricts the answer to a project, the same way DiscoverPrivate does", + "type": "string" + }, + "projectVersion": { + "description": "Only meaningful alongside project_name: version names are unique within a project", + "type": "string" + } + }, + "title": "Referrer Service Discover Edges Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json new file mode 100644 index 000000000..61e2d3f51 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json @@ -0,0 +1,35 @@ +{ + "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method", + "patternProperties": { + "^(projectName)$": { + "description": "Restricts the answer to a project, the same way DiscoverPrivate does", + "type": "string" + }, + "^(projectVersion)$": { + "description": "Only meaningful alongside project_name: version names are unique within a project", + "type": "string" + } + }, + "properties": { + "nodes": { + "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.", + "items": { + "$ref": "controlplane.v1.ReferrerRef.schema.json" + }, + "type": "array" + }, + "project_name": { + "description": "Restricts the answer to a project, the same way DiscoverPrivate does", + "type": "string" + }, + "project_version": { + "description": "Only meaningful alongside project_name: version names are unique within a project", + "type": "string" + } + }, + "title": "Referrer Service Discover Edges Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json new file mode 100644 index 000000000..522af8352 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json @@ -0,0 +1,17 @@ +{ + "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", + "properties": { + "edges": { + "description": "Each connection once, regardless of the direction it is stored in", + "items": { + "$ref": "controlplane.v1.ReferrerEdge.jsonschema.json" + }, + "type": "array" + } + }, + "title": "Referrer Service Discover Edges Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json new file mode 100644 index 000000000..26810c460 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json @@ -0,0 +1,17 @@ +{ + "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", + "properties": { + "edges": { + "description": "Each connection once, regardless of the direction it is stored in", + "items": { + "$ref": "controlplane.v1.ReferrerEdge.schema.json" + }, + "type": "array" + } + }, + "title": "Referrer Service Discover Edges Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index a5036f3b3..da9b3b42f 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -22,6 +22,39 @@ externalDocs: description: Chainloop Official Documentation url: https://docs.chainloop.dev paths: + /discover/edges: + post: + description: >- + Returns every connection between the given referrers that is visible to + the logged-in user, as pairs of indexes into the request. Referrers the + user cannot see contribute no edges. + operationId: ReferrerService_DiscoverEdges + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/v1ReferrerServiceDiscoverEdgesRequest' + description: Request to discover the edges between a set of referrers + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/v1ReferrerServiceDiscoverEdgesResponse' + description: A successful response. + default: + content: + application/json: + schema: + $ref: '#/components/schemas/rpcStatus' + description: An unexpected error response. + summary: Discover the edges between known referrers + tags: + - ReferrerService + x-codegen-request-body-name: body + security: + - bearerToken: [] /discover/{digest}: get: description: >- @@ -204,7 +237,7 @@ components: type: object rpcStatus: example: - code: 0 + code: 1 details: - '@type': '@type' - '@type': '@type' @@ -236,6 +269,22 @@ components: next_cursor: type: string type: object + v1ReferrerEdge: + description: A connection between two referrers, as indexes into the request + example: + from: 0 + to: 6 + properties: + from: + format: int64 + title: Index of the lower end of the connection, always smaller than to + type: integer + to: + format: int64 + title: Index of the higher end of the connection + type: integer + title: ReferrerEdge + type: object v1ReferrerItem: description: It represents a referrer object in the system example: @@ -285,6 +334,55 @@ components: type: object title: ReferrerItem type: object + v1ReferrerRef: + description: A reference to a referrer by digest and kind + properties: + digest: + title: Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead + type: string + kind: + description: Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... + type: string + title: ReferrerRef + type: object + v1ReferrerServiceDiscoverEdgesRequest: + description: Request to discover the edges between a set of referrers + properties: + nodes: + description: >- + The referrers to find connections between. A digest alone does not + identify a referrer, so + + the kind travels with it. + items: + $ref: '#/components/schemas/v1ReferrerRef' + type: array + project_name: + title: Restricts the answer to a project, the same way DiscoverPrivate does + type: string + project_version: + title: >- + Only meaningful alongside project_name: version names are unique + within a project + type: string + title: ReferrerServiceDiscoverEdgesRequest + type: object + v1ReferrerServiceDiscoverEdgesResponse: + description: Response for the DiscoverEdges method + example: + edges: + - from: 0 + to: 6 + - from: 0 + to: 6 + properties: + edges: + items: + $ref: '#/components/schemas/v1ReferrerEdge' + title: Each connection once, regardless of the direction it is stored in + type: array + title: ReferrerServiceDiscoverEdgesResponse + type: object v1ReferrerServiceDiscoverPrivateResponse: description: Response for the DiscoverPrivate method example: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 883aca7db..333deea7e 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -136,3 +136,56 @@ func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem { return item } + +// DiscoverEdges returns the connections between referrers the caller already holds, so a client +// that has collected a set of them does not have to ask about each one separately. +func (s *ReferrerService) DiscoverEdges(ctx context.Context, req *pb.ReferrerServiceDiscoverEdgesRequest) (*pb.ReferrerServiceDiscoverEdgesResponse, error) { + currentUser, currentToken, err := requireCurrentUserOrAPIToken(ctx) + if err != nil { + return nil, err + } + + currentOrg, err := requireCurrentOrg(ctx) + if err != nil { + return nil, err + } + + nodes := make([]*biz.ReferrerRef, 0, len(req.GetNodes())) + for _, n := range req.GetNodes() { + nodes = append(nodes, &biz.ReferrerRef{Digest: n.GetDigest(), Kind: n.GetKind()}) + } + + var extraFilters []biz.GetFromRootFilter + if req.GetProjectName() != "" { + extraFilters = append(extraFilters, biz.WithProjectScope(req.GetProjectName(), req.GetProjectVersion())) + } + + var edges []biz.ReferrerEdge + switch { + case currentUser != nil: + edges, err = s.referrerUC.EdgesAmongUser(ctx, nodes, currentUser.ID, extraFilters...) + case currentToken != nil: + orgUUID, parseErr := uuid.Parse(currentOrg.ID) + if parseErr != nil { + return nil, fmt.Errorf("invalid org UUID: %w", parseErr) + } + + // An API token is scoped to one organization, and may be scoped to some of its projects. + orgsProjectsMap := make(map[uuid.UUID][]uuid.UUID) + if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil { + orgsProjectsMap[orgUUID] = visibleProjects + } + + edges, err = s.referrerUC.EdgesAmong(ctx, nodes, []uuid.UUID{orgUUID}, orgsProjectsMap, extraFilters...) + } + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + result := make([]*pb.ReferrerEdge, 0, len(edges)) + for _, e := range edges { + result = append(result, &pb.ReferrerEdge{From: uint32(e.From), To: uint32(e.To)}) + } + + return &pb.ReferrerServiceDiscoverEdgesResponse{Edges: result}, nil +} diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 471338e60..0118305a8 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -362,6 +362,7 @@ var RolesMap = map[Role][]*Policy{ var ServerOperationsMap = map[string]*OperationPolicy{ // Discover endpoint "/controlplane.v1.ReferrerService/DiscoverPrivate": {Policies: []*Policy{PolicyReferrerRead}}, + "/controlplane.v1.ReferrerService/DiscoverEdges": {Policies: []*Policy{PolicyReferrerRead}}, // Download/Uploading artifacts // There are no policies for the download endpoint, we do a manual check in the service layer // to differentiate between upload and download requests diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 06bbfb019..743761d60 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -69,6 +69,22 @@ type ReferrerRepo interface { // 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) + // EdgesAmong returns the connections between the given referrers, as index pairs into nodes. + // Referrers the caller cannot see contribute no edges. Each connection is reported once even + // though the store holds both of its directions. + EdgesAmong(ctx context.Context, nodes []*ReferrerRef, orgIDs []uuid.UUID, filters ...GetFromRootFilter) ([]ReferrerEdge, error) +} + +// ReferrerRef identifies a referrer. A digest alone is not enough: the same digest can be stored +// under more than one kind. +type ReferrerRef struct { + Digest, Kind string +} + +// ReferrerEdge is a connection between two referrers, given as indexes into the slice the caller +// asked about. From is always the smaller index. +type ReferrerEdge struct { + From, To int } type Referrer struct { @@ -210,6 +226,49 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri return ref, nextCursor, nil } +// EdgesAmongUser returns the connections between the given referrers that the user is allowed to +// see. It is the companion of GetFromRootUser: a caller that has already collected a set of +// referrers uses it to learn how they connect, instead of asking about each one separately. +func (s *ReferrerUseCase) EdgesAmongUser(ctx context.Context, nodes []*ReferrerRef, userID string, extraFilters ...GetFromRootFilter) ([]ReferrerEdge, error) { + ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.EdgesAmongUser") + defer span.End() + + userUUID, err := uuid.Parse(userID) + if err != nil { + return nil, NewErrInvalidUUID(err) + } + + userOrgs, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) + if err != nil { + return nil, err + } + + return s.EdgesAmong(ctx, nodes, userOrgs, scopes.ProjectIDsByOrg(), extraFilters...) +} + +// EdgesAmong returns the connections between the given referrers visible in the given orgs. +func (s *ReferrerUseCase) EdgesAmong(ctx context.Context, nodes []*ReferrerRef, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, extraFilters ...GetFromRootFilter) ([]ReferrerEdge, error) { + ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.EdgesAmong") + defer span.End() + + if len(nodes) < 2 { + return nil, nil + } + + filters := make([]GetFromRootFilter, 0, len(extraFilters)+1) + if projectIDs != nil { + filters = append(filters, WithVisibleProjectIDs(projectIDs)) + } + filters = append(filters, extraFilters...) + + edges, err := s.repo.EdgesAmong(ctx, nodes, orgIDs, filters...) + if err != nil { + return nil, fmt.Errorf("getting edges: %w", err) + } + + return edges, nil +} + const ( // ReferrerAttestationType is the kind of the referrer that represents an attestation. ReferrerAttestationType = "ATTESTATION" diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index e73eb6207..295727681 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -653,3 +653,143 @@ func (s *referrerIntegrationTestSuite) SetupTest() { func TestReferrerIntegration(t *testing.T) { suite.Run(t, new(referrerIntegrationTestSuite)) } + +// TestEdgesAmong covers the endpoint a client uses once it already holds a set of referrers and +// only needs to know how they connect. +func (s *referrerIntegrationTestSuite) TestEdgesAmong() { + ctx := context.Background() + envelope, envBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") + attDigest, _, err := v1.SHA256(bytes.NewReader(envBytes)) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow1.ID.String())) + + // Read back what the attestation is connected to, so the test does not hardcode the fixture. + root, _, err := s.Referrer.GetFromRootUser(ctx, attDigest.String(), "", s.user.ID, nil) + require.NoError(s.T(), err) + require.NotEmpty(s.T(), root.References) + + attestation := &biz.ReferrerRef{Digest: root.Digest, Kind: root.Kind} + materials := make([]*biz.ReferrerRef, 0, len(root.References)) + for _, ref := range root.References { + materials = append(materials, &biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}) + } + + s.Run("every material is reported as connected to the attestation, once", func() { + nodes := append([]*biz.ReferrerRef{attestation}, materials...) + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err) + s.Len(edges, len(materials), "one edge per material, not one per stored direction") + + for _, e := range edges { + s.Less(e.From, e.To, "edges are normalised so the lower index comes first") + s.Equal(0, e.From, "every edge in this fixture hangs off the attestation at index 0") + } + }) + + s.Run("the order the caller gives is the order the indexes refer to", func() { + // Same set, attestation last: the indexes must follow. + nodes := append(append([]*biz.ReferrerRef{}, materials...), attestation) + attestationIndex := len(nodes) - 1 + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err) + s.Len(edges, len(materials)) + for _, e := range edges { + s.Equal(attestationIndex, e.To, "the attestation is now the higher index of every pair") + } + }) + + s.Run("materials alone have no edges between them", func() { + if len(materials) < 2 { + s.T().Skip("fixture has a single material") + } + edges, err := s.Referrer.EdgesAmongUser(ctx, materials, s.user.ID) + s.NoError(err) + s.Empty(edges, "materials are connected through their attestation, not to each other") + }) + + s.Run("a referrer the caller cannot see contributes no edges", func() { + nodes := append([]*biz.ReferrerRef{attestation}, materials...) + // user2 belongs to org2 only, where none of this was ingested. + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user2.ID) + s.NoError(err) + s.Empty(edges) + }) + + s.Run("an unknown referrer is ignored rather than failing", func() { + nodes := []*biz.ReferrerRef{ + attestation, + {Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", Kind: "ATTESTATION"}, + } + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err) + s.Empty(edges) + }) + + s.Run("the kind is part of the identity", func() { + // The same digest under a kind it was not stored as must not resolve. + nodes := []*biz.ReferrerRef{ + {Digest: attestation.Digest, Kind: "CONTAINER_IMAGE"}, + materials[0], + } + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err) + s.Empty(edges) + }) + + s.Run("fewer than two referrers is answered without touching the store", func() { + edges, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation}, s.user.ID) + s.NoError(err) + s.Empty(edges) + }) + + s.Run("duplicated input still yields one edge per connection", func() { + nodes := []*biz.ReferrerRef{attestation, materials[0], materials[0]} + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err) + // The repeated material resolves to the same referrer, which the caller listed twice; the + // answer names whichever position it was indexed under, once. + s.Len(edges, 1) + }) +} + +// TestEveryConnectionIsWrittenFromTheAttestation pins the property EdgesAmong depends on to stay +// bounded: it reads connections from the attestation side, so ingest must always write that row. +// A future change that only recorded the material's side would make edges disappear from the +// graph rather than fail loudly. +func (s *referrerIntegrationTestSuite) TestEveryConnectionIsWrittenFromTheAttestation() { + ctx := context.Background() + + // Cover the shapes ingest produces: materials, a git subject, and a dependent attestation. + dependent, _ := testEnvelope(s.T(), "testdata/attestations/dependent-attestation.json") + dependentDigest, _ := v1.NewHash("sha256:2dc17f7c933d20e06b49250a582a3d19bdfbadba9c4e5f3f856af6f261db79d4") + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, dependent, dependentDigest, s.workflow1.ID.String())) + + withDependent, _ := testEnvelope(s.T(), "testdata/attestations/with-dependent-attestation.json") + withDependentDigest, _ := v1.NewHash("sha256:950c7b4c65447a3b86b6f769515005e7c44a67c8193bff790750eadf13207fbb") + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, withDependent, withDependentDigest, s.workflow1.ID.String())) + + gitSubject, gitBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") + gitDigest, _, err := v1.SHA256(bytes.NewReader(gitBytes)) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, gitSubject, gitDigest, s.workflow1.ID.String())) + + // Every stored connection must have a row whose owning side is an attestation. + var orphaned int + row := s.Data.SQLDB.QueryRowContext(ctx, ` + SELECT count(*) FROM referrer_references rr + JOIN referrers owner ON owner.id = rr.referrer_id + WHERE owner.kind <> 'ATTESTATION' + AND NOT EXISTS ( + SELECT 1 FROM referrer_references mirror + JOIN referrers mirror_owner ON mirror_owner.id = mirror.referrer_id + WHERE mirror.referrer_id = rr.referred_by_id + AND mirror.referred_by_id = rr.referrer_id + AND mirror_owner.kind = 'ATTESTATION' + )`) + require.NoError(s.T(), row.Scan(&orphaned)) + s.Zero(orphaned, "every connection must be reachable from the attestation that created it") + + var total int + require.NoError(s.T(), s.Data.SQLDB.QueryRowContext(ctx, `SELECT count(*) FROM referrer_references`).Scan(&total)) + s.NotZero(total, "the fixture must actually have written connections") +} diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 34a31dd23..93747e5ba 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -20,6 +20,7 @@ import ( "fmt" "slices" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" @@ -433,8 +434,12 @@ 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(). - // referrer_references is keyed on (referrer_id, referred_by_id), so a reference cannot - // repeat and the DISTINCT Ent adds by default only buys a sort over the jsonb columns. + // Ent adds a DISTINCT to any traversal by default. Here it can only cost: the selected + // columns include the referrer's primary key, so deduplicating them is a no-op unless a + // predicate multiplies rows, and none of the predicates above can — they are semi-joins + // (EXISTS, IN) and scalar comparisons. Dropping it keeps the jsonb columns out of the + // sort key. Any predicate added here must stay join-free or this silently starts + // returning a reference more than once, which would also shorten the page. Unique(false). Order(referrer.ByCreatedAt(sql.OrderDesc())). Order(referrer.ByID(sql.OrderDesc())). @@ -546,3 +551,170 @@ func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) { out.WorkflowIDs = workflowIDs out.OrgIDs = orgIDs } + +// EdgesAmong returns the connections between the given referrers, as index pairs into nodes. +// +// The work is bounded by the number of referrers asked about, not by how many references any of +// them has: a shared material can be referenced by a hundred thousand attestations, and asking +// "which of these hundred nodes are connected" must not walk that. It therefore reads the store +// from the attestation side of each connection, whose degree is the number of materials in one +// attestation rather than the number of attestations sharing a material. +// +// That is complete because of how connections are written: a connection only ever exists between +// an attestation and one of its materials or subjects (or another attestation it depends on), and +// the attestation's own row is always written, since ingest builds its reference list from every +// material and subject it carries. The material's row pointing back is written too, but it is not +// what this relies on — older data holds many connections in the attestation direction only. +func (r *ReferrerRepo) EdgesAmong(ctx context.Context, nodes []*biz.ReferrerRef, orgIDs []uuid.UUID, filters ...biz.GetFromRootFilter) ([]biz.ReferrerEdge, error) { + ctx, span := otelx.Start(ctx, referrerRepoTracer, "ReferrerRepo.EdgesAmong") + defer span.End() + + if len(nodes) < 2 { + return nil, nil + } + + opts := &biz.GetFromRootFilters{} + for _, f := range filters { + f(opts) + } + + // Resolve the referrers the caller may see. Anything invisible or unknown simply does not + // come back and so contributes no edges. + predicates := []predicate.Referrer{ + referrerDigestKindIn(nodes), + referrerVisibleToOrgs(orgIDs), + } + if opts.ProjectName != nil && *opts.ProjectName != "" { + version := "" + if opts.ProjectVersion != nil { + version = *opts.ProjectVersion + } + projectPred := r.projectScopePredicate(*opts.ProjectName, version, orgIDs, opts.ProjectIDs) + predicates = append(predicates, referrer.Or(referrer.KindNEQ(biz.ReferrerAttestationType), projectPred)) + } + + visible, err := r.data.DB.Referrer.Query(). + Where(predicates...). + Select(referrer.FieldID, referrer.FieldDigest, referrer.FieldKind). + All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to resolve referrers: %w", err) + } + if len(visible) < 2 { + return nil, nil + } + + // Index the answer by the position the caller gave each referrer, so only positions travel back. + indexOf := make(map[string]int, len(nodes)) + for i, n := range nodes { + indexOf[newRefKey(n.Digest, n.Kind)] = i + } + position := make(map[uuid.UUID]int, len(visible)) + attestationIDs := make([]uuid.UUID, 0, len(visible)) + visibleIDs := make([]uuid.UUID, 0, len(visible)) + for _, ref := range visible { + i, ok := indexOf[newRefKey(ref.Digest, ref.Kind)] + if !ok { + continue + } + position[ref.ID] = i + visibleIDs = append(visibleIDs, ref.ID) + if ref.Kind == biz.ReferrerAttestationType { + attestationIDs = append(attestationIDs, ref.ID) + } + } + if len(attestationIDs) == 0 || len(visibleIDs) < 2 { + return nil, nil + } + + // Built with the dialect's builder so the placeholders are numbered for it, and run on the + // raw handle because the join table has no entity of its own to query through. + query, args := edgesAmongQuery(attestationIDs, visibleIDs) + rows, err := r.data.SQLDB.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query edges: %w", err) + } + defer rows.Close() + + // A connection can be read twice: once from each end when both ends are attestations, and + // once per stored direction where ingest wrote both. The pair is normalised and deduplicated + // so the caller sees each connection once. + seen := make(map[biz.ReferrerEdge]struct{}) + edges := make([]biz.ReferrerEdge, 0) + for rows.Next() { + var from, to uuid.UUID + if err := rows.Scan(&from, &to); err != nil { + return nil, fmt.Errorf("failed to scan edge: %w", err) + } + a, okA := position[from] + b, okB := position[to] + if !okA || !okB || a == b { + continue + } + if a > b { + a, b = b, a + } + edge := biz.ReferrerEdge{From: a, To: b} + if _, dup := seen[edge]; dup { + continue + } + seen[edge] = struct{}{} + edges = append(edges, edge) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read edges: %w", err) + } + + return edges, nil +} + +// referrerDigestKindIn matches any of the given referrers, pairing each digest with its kind so a +// digest stored under two kinds cannot be confused for the other. It is a single composite IN +// rather than a chain of ORs, which keeps it usable through the unique index on (digest, kind). +func referrerDigestKindIn(nodes []*biz.ReferrerRef) predicate.Referrer { + return func(s *sql.Selector) { + s.Where(sql.P().Append(func(b *sql.Builder) { + b.Wrap(func(nb *sql.Builder) { + nb.IdentComma(s.C(referrer.FieldDigest), s.C(referrer.FieldKind)) + }) + b.WriteString(" IN ") + b.Wrap(func(nb *sql.Builder) { + for i, n := range nodes { + if i > 0 { + nb.Comma() + } + nb.Wrap(func(vb *sql.Builder) { + vb.Args(n.Digest, n.Kind) + }) + } + }) + })) + } +} + +// edgesAmongQuery reads the join table from the attestation side: for each attestation it walks +// only that attestation's own references, restricted to the referrers the caller asked about. +// Both columns are the table's primary key, so this is an index scan whose size is the number of +// materials in the attestations involved. +func edgesAmongQuery(fromIDs, toIDs []uuid.UUID) (string, []any) { + t := sql.Dialect(dialect.Postgres).Table(referrer.ReferencesTable) + from := make([]any, 0, len(fromIDs)) + for _, id := range fromIDs { + from = append(from, id) + } + to := make([]any, 0, len(toIDs)) + for _, id := range toIDs { + to = append(to, id) + } + + return sql.Dialect(dialect.Postgres). + Select(referrer.ReferencesPrimaryKey[0], referrer.ReferencesPrimaryKey[1]). + From(t). + Where(sql.And( + sql.In(referrer.ReferencesPrimaryKey[0], from...), + sql.In(referrer.ReferencesPrimaryKey[1], to...), + )). + Query() +} + +func newRefKey(digest, kind string) string { return kind + "-" + digest } From 2f246b0f6786ef3e0ae854a1995d99313d03df38 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 09:38:38 +0200 Subject: [PATCH 05/15] docs(controlplane): fix two typos in the referrer save comments Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/data/referrer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 93747e5ba..46d1e697c 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -97,10 +97,10 @@ func (r *ReferrerRepo) Save(ctx context.Context, referrers []*biz.Referrer, work for _, parentRef := range referrers { // This is the current item stored in DB storedReferrer := storedMap[parentRef.MapID()] - // Iterate on the items it refer to (references) + // Iterate on the items it refers to (references) var references []uuid.UUID for _, ref := range parentRef.References { - // amd find it in the DB + // and find it in the DB storedReference, ok := storedMap[ref.MapID()] if !ok { return fmt.Errorf("referrer %v not found", ref) From ee8da7afe16fee0f9232c64bbd2523efcb251b5c Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 10:16:12 +0200 Subject: [PATCH 06/15] fix(controlplane): apply project visibility to DiscoverEdges on every request The endpoint read the caller's role-based project grants only when the request named a project, so a request without one resolved its referrers on organization membership alone. A member restricted to a subset of an organization's projects was refused those referrers by DiscoverPrivate and told by DiscoverEdges that they are connected. No content crossed the boundary, but the existence of a relationship did, and one request can ask about a thousand referrers. An optional filter narrows what the caller asked for. It cannot be what decides what they are allowed to see, so the grants now bound which referrers resolve on every request. The project is matched on its own row rather than through the workflow's organization column, so a grant cannot be satisfied by a project that lives in a different organization to the one it was granted in. The regression test states the rule as the comparison that matters: for the same caller and the same referrers, this endpoint must reveal no more than the one it sits beside. It fails against the previous predicate. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- .../pkg/biz/referrer_integration_test.go | 24 ++++++++++ app/controlplane/pkg/data/referrer.go | 45 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 295727681..5183705ec 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -686,6 +686,30 @@ func (s *referrerIntegrationTestSuite) TestEdgesAmong() { } }) + // The caller's role-based project visibility is not an optional filter: a member who cannot + // reach the project must not learn that two of its referrers are connected, whether or not + // they name a project in the request. + s.Run("a caller restricted to no project in the org gets no edges", func() { + nodes := append([]*biz.ReferrerRef{attestation}, materials...) + // RBAC on for the org, nothing granted in it. + noProjects := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {}} + edges, err := s.Referrer.EdgesAmong(ctx, nodes, []uuid.UUID{s.org1UUID}, noProjects) + s.NoError(err) + s.Empty(edges, "org membership alone must not reveal connections") + + // And the established endpoint agrees for the same caller. + _, _, err = s.Referrer.GetFromRoot(ctx, attestation.Digest, attestation.Kind, []uuid.UUID{s.org1UUID}, noProjects, nil) + s.Error(err, "the reference endpoint denies this caller, so the edges endpoint must too") + }) + + s.Run("a caller granted the project still gets every edge", func() { + nodes := append([]*biz.ReferrerRef{attestation}, materials...) + granted := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} + edges, err := s.Referrer.EdgesAmong(ctx, nodes, []uuid.UUID{s.org1UUID}, granted) + s.NoError(err) + s.Len(edges, len(materials), "a granted project must not lose any connection") + }) + s.Run("the order the caller gives is the order the indexes refer to", func() { // Same set, attestation last: the indexes must follow. nodes := append(append([]*biz.ReferrerRef{}, materials...), attestation) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 46d1e697c..dc008f639 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -289,6 +289,46 @@ func referrerVisibleToOrgs(orgIDs []uuid.UUID) predicate.Referrer { } } +// referrerVisibleInProjects narrows referrerVisibleToOrgs to the projects the caller can +// actually see. Organization membership alone is not visibility: a member with role-based +// restrictions reaches only the projects granted to them, and a referrer that belongs to no +// other project must not be reachable through any endpoint. +// +// The project is matched on its own row rather than on the workflow's organization column, so a +// grant cannot be satisfied by a project that lives in a different organization to the one it +// was granted in. +func referrerVisibleInProjects(orgIDs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) predicate.Referrer { + scope := projectVisibilityPredicate(orgIDs, visibleProjectsMap) + if scope == nil { + // No organization grants any project, so nothing is visible. + return func(s *sql.Selector) { s.Where(sql.False()) } + } + + return func(s *sql.Selector) { + joinTable := sql.Table(referrer.WorkflowsTable).As("scoped_rw") + workflows := sql.Table(referrer.WorkflowsInverseTable).As("scoped_wf") + projects := sql.Table(project.Table).As("scoped_p") + + visibleProject := sql.Dialect(s.Dialect()). + Select(projects.C(project.FieldID)). + From(projects). + Where(sql.ColumnsEQ(projects.C(project.FieldID), workflows.C(workflow.FieldProjectID))) + scope(visibleProject) + + sub := sql.Dialect(s.Dialect()). + Select(joinTable.C(referrer.WorkflowsPrimaryKey[0])). + From(joinTable). + Join(workflows). + On(joinTable.C(referrer.WorkflowsPrimaryKey[1]), workflows.C(workflow.FieldID)). + Where(sql.And( + sql.ColumnsEQ(joinTable.C(referrer.WorkflowsPrimaryKey[0]), s.C(referrer.FieldID)), + sql.IsNull(workflows.C(workflow.FieldDeletedAt)), + sql.Exists(visibleProject), + )) + s.Where(sql.Exists(sub)) + } +} + // projectVisibilityPredicate builds a project predicate that accepts a project iff it belongs to // one of the allowed orgs AND, when RBAC applies to that org, the project is in the caller's // visible set. Returns nil when no org grants any project visibility, so callers can treat that @@ -580,9 +620,12 @@ func (r *ReferrerRepo) EdgesAmong(ctx context.Context, nodes []*biz.ReferrerRef, // Resolve the referrers the caller may see. Anything invisible or unknown simply does not // come back and so contributes no edges. + // Role-based project visibility applies to every request, not only to the ones that name a + // project: the optional filter below narrows what the caller asked for, it does not decide + // what they are allowed to see. predicates := []predicate.Referrer{ referrerDigestKindIn(nodes), - referrerVisibleToOrgs(orgIDs), + referrerVisibleInProjects(orgIDs, opts.ProjectIDs), } if opts.ProjectName != nil && *opts.ProjectName != "" { version := "" From 599e0a9df2e501af0fbf6bada4d1ffc347482407 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 11:08:05 +0200 Subject: [PATCH 07/15] test(controlplane): cover the DiscoverEdges RPC at the service layer The RPC picks between two authorization paths and maps referrers onto the indexes the caller gave. Neither was covered: the use-case tests enter below the switch, so the routing and the index mapping could both regress while everything still passed. Covers the logged-in user path, the API token path, a referrer the caller cannot resolve, and a call with no credential at all. Project scoping is deliberately left out. It resolves through workflow runs and this fixture has none, so every project name answers the same way and any assertion would hold for the wrong reason. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- .../referrer_edges_integration_test.go | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 app/controlplane/internal/service/referrer_edges_integration_test.go diff --git a/app/controlplane/internal/service/referrer_edges_integration_test.go b/app/controlplane/internal/service/referrer_edges_integration_test.go new file mode 100644 index 000000000..dfc731dbe --- /dev/null +++ b/app/controlplane/internal/service/referrer_edges_integration_test.go @@ -0,0 +1,155 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "testing" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "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/usercontext/entities" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/secure-systems-lab/go-securesystemslib/dsse" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// The RPC decides which of two authorization paths to take and maps referrers onto the indexes +// the caller gave. Neither is exercised by the use-case tests, so both are covered here through +// the service itself. +func TestReferrerEdgesServiceIntegration(t *testing.T) { + suite.Run(t, new(referrerEdgesServiceSuite)) +} + +type referrerEdgesServiceSuite struct { + testhelpers.UseCasesEachTestSuite + + svc *service.ReferrerService + org *biz.Organization + user *biz.User + workflow *biz.Workflow + apiToken *biz.APIToken + attestation *biz.ReferrerRef + materials []*biz.ReferrerRef +} + +func (s *referrerEdgesServiceSuite) SetupTest() { + ctx := context.Background() + s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) + s.svc = service.NewReferrerService(s.Referrer) + + var err error + s.org, err = s.Organization.CreateWithRandomName(ctx) + require.NoError(s.T(), err) + s.user, err = s.User.UpsertByEmail(ctx, "edges@chainloop.local", nil) + require.NoError(s.T(), err) + _, err = s.Membership.Create(ctx, s.org.ID, s.user.ID, biz.WithCurrentMembership()) + require.NoError(s.T(), err) + + s.workflow, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ + OrgID: s.org.ID, Name: "edges-wf", Project: "edges-project", + }) + require.NoError(s.T(), err) + + s.apiToken, err = s.APIToken.Create(ctx, "edges-token", nil, nil, &s.org.ID) + require.NoError(s.T(), err) + + envelope, raw := edgesEnvelope(s.T()) + digest, _, err := v1.SHA256(bytes.NewReader(raw)) + require.NoError(s.T(), err) + require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, digest, s.workflow.ID.String())) + + root, _, err := s.Referrer.GetFromRootUser(ctx, digest.String(), "", s.user.ID, nil) + require.NoError(s.T(), err) + require.NotEmpty(s.T(), root.References) + + s.attestation = &biz.ReferrerRef{Digest: root.Digest, Kind: root.Kind} + for _, ref := range root.References { + s.materials = append(s.materials, &biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}) + } +} + +func (s *referrerEdgesServiceSuite) userCtx() context.Context { + ctx := entities.WithCurrentUser(context.Background(), &entities.User{ID: s.user.ID, Email: s.user.Email}) + return entities.WithCurrentOrg(ctx, &entities.Org{ID: s.org.ID, Name: s.org.Name}) +} + +func (s *referrerEdgesServiceSuite) tokenCtx() context.Context { + ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) + ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: s.apiToken.ID.String(), Name: s.apiToken.Name}) + return usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: s.apiToken.ID.String()}).String()) +} + +func (s *referrerEdgesServiceSuite) request(nodes ...*biz.ReferrerRef) *pb.ReferrerServiceDiscoverEdgesRequest { + out := make([]*pb.ReferrerRef, 0, len(nodes)) + for _, n := range nodes { + out = append(out, &pb.ReferrerRef{Digest: n.Digest, Kind: n.Kind}) + } + return &pb.ReferrerServiceDiscoverEdgesRequest{Nodes: out} +} + +func (s *referrerEdgesServiceSuite) TestDiscoverEdges() { + s.Run("a logged-in user gets one edge per material, indexed by request position", func() { + nodes := append([]*biz.ReferrerRef{s.attestation}, s.materials...) + res, err := s.svc.DiscoverEdges(s.userCtx(), s.request(nodes...)) + s.Require().NoError(err) + s.Len(res.GetEdges(), len(s.materials)) + for _, e := range res.GetEdges() { + s.Less(e.GetFrom(), e.GetTo(), "indexes come back normalised") + s.EqualValues(0, e.GetFrom(), "the attestation is at index 0 of this request") + } + }) + + s.Run("a referrer the caller cannot see contributes no edges", func() { + unknown := &biz.ReferrerRef{Digest: "sha256:" + "00", Kind: "ATTESTATION"} + res, err := s.svc.DiscoverEdges(s.userCtx(), s.request(s.attestation, unknown)) + s.Require().NoError(err) + s.Empty(res.GetEdges(), "an unresolvable node must not fail the call") + }) + + s.Run("no credential is refused", func() { + _, err := s.svc.DiscoverEdges(context.Background(), s.request(s.attestation, s.materials[0])) + s.Error(err) + }) + + s.Run("an API token resolves through its own organization", func() { + nodes := append([]*biz.ReferrerRef{s.attestation}, s.materials...) + res, err := s.svc.DiscoverEdges(s.tokenCtx(), s.request(nodes...)) + s.Require().NoError(err) + s.Len(res.GetEdges(), len(s.materials), "the token path must see what the user path sees") + }) + + // Project scoping is deliberately not covered here. It resolves through workflow runs, and + // this fixture has none, so every project name would answer the same way and the assertion + // would hold for the wrong reason. It belongs where a run fixture already exists. +} + +func edgesEnvelope(t *testing.T) (*dsse.Envelope, []byte) { + raw, err := os.ReadFile("../../pkg/biz/testdata/attestations/with-git-subject.json") + require.NoError(t, err) + var envelope *dsse.Envelope + require.NoError(t, json.Unmarshal(raw, &envelope)) + + return envelope, raw +} From 21c9add58c49b5da88efc941e7917544a167f2e8 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 11:18:15 +0200 Subject: [PATCH 08/15] docs(controlplane): state what the referrer predicates do, not how they plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments asserted a query plan: that the generated IN form makes the planner materialise every referrer in the caller's organizations, that correlating turns it into a per-candidate lookup, and that the project filter plans as a semi-join on the run digest index. Measurement does not support the first two — both visibility forms plan identically and run within noise of each other at every node degree tried, on two datasets — and the third was never checked. They now say what the code expresses and what has to stay true, which is the part that does not move with the table statistics: the join table's key is why the DISTINCT is a no-op, and a predicate that joins the outer query is what would make it matter. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/data/referrer.go | 32 ++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index dc008f639..f807082c6 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -211,10 +211,8 @@ func (r *ReferrerRepo) GetFromRoot(ctx context.Context, digest string, orgIDs [] // projectScopePredicate returns a predicate matching referrers whose digest is the attestation // digest of a workflow run in the requested project (and, when non-empty, version), visible to -// the caller. The predicate compiles to a SQL subquery — no digest list is materialized in Go, -// so the cost is independent of how many runs the project has. Postgres plans this as a -// semi-join via the index on workflow_run.attestation_digest, which is what makes the filter -// scale at thousands of runs per project. +// the caller. The predicate compiles to a SQL subquery rather than materializing a digest list in +// Go, so the request carries no per-run data however many runs the project has. // // Visibility mirrors isReferrerVisible: a run is included when its workflow's project is in the // caller's RBAC-visible set. visibleProjectsMap follows the existing convention — an org entry @@ -261,11 +259,10 @@ func (r *ReferrerRepo) projectScopePredicate(projectName, version string, orgIDs // given organizations. // // It is written by hand rather than with referrer.HasWorkflowsWith because the generated form -// emits an uncorrelated `id IN (subquery)`: the planner materialises every referrer reachable by -// the caller's organizations and joins afterwards, which on a hub node means scanning the whole -// join table. Correlating the subquery to the referrer row turns it into a lookup per candidate. -// The organization is matched on the workflow's own column for the same reason the project filter -// does: a nested relation predicate here is flattened back into the uncorrelated shape. +// renders as `id IN (subquery)`, which describes a set to build rather than a condition to test. +// Correlating the subquery to the referrer row says what is meant: for this referrer, does such a +// workflow exist. The organization is matched on the workflow's own column rather than through a +// nested relation predicate, which Ent renders back into the uncorrelated shape. func referrerVisibleToOrgs(orgIDs []uuid.UUID) predicate.Referrer { orgs := make([]any, 0, len(orgIDs)) for _, id := range orgIDs { @@ -474,12 +471,17 @@ 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(). - // Ent adds a DISTINCT to any traversal by default. Here it can only cost: the selected - // columns include the referrer's primary key, so deduplicating them is a no-op unless a - // predicate multiplies rows, and none of the predicates above can — they are semi-joins - // (EXISTS, IN) and scalar comparisons. Dropping it keeps the jsonb columns out of the - // sort key. Any predicate added here must stay join-free or this silently starts - // returning a reference more than once, which would also shorten the page. + // Ent adds a DISTINCT to any traversal by default, because an edge query can multiply + // rows. This one cannot: the join table is keyed on (referrer_id, referred_by_id), so a + // fixed root matches each referrer at most once, and the selected columns include the + // primary key, so there is nothing to collapse. Every predicate above is a semi-join + // (EXISTS, IN) or a scalar comparison. + // + // Any predicate added here must stay join-free. A join would return a reference twice, + // which consumes a slot in the page and leaves the cursor on the wrong row, so references + // are skipped rather than repeated. sql.OrPredicates applies each predicate to this same + // selector, so a joining predicate inside an Or leaks its join here while its WHERE is + // OR-ed away. Unique(false). Order(referrer.ByCreatedAt(sql.OrderDesc())). Order(referrer.ByID(sql.OrderDesc())). From 7be24830426bf55a26e26a631fc6bd698fadf668 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 11:33:16 +0200 Subject: [PATCH 09/15] fix(controlplane): define how DiscoverEdges answers a repeated referrer The request's nodes are a set, and a caller assembling them from more than one attestation will list a shared material twice. That was already accepted, but which of the repeated positions the answer named fell out of a map overwrite, so a client that drew a node per position had one copy connected and the other floating with no way to tell why. A repeated referrer is now answered at the position it first appeared in, and the request message says so. Naming fewer than two distinct referrers is refused rather than answered with an empty set: two positions holding one referrer describe no connection, and reading that as "not connected" hides the mistake. The test that covered this asserted only how many edges came back, so it held for either position and pinned nothing. It now asserts the indexes. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- .../api/controlplane/v1/referrer.pb.go | 7 +- .../api/controlplane/v1/referrer.proto | 7 +- .../gen/frontend/controlplane/v1/referrer.ts | 9 +- ...erviceDiscoverEdgesRequest.jsonschema.json | 2 +- ...rerServiceDiscoverEdgesRequest.schema.json | 2 +- ...rviceDiscoverEdgesResponse.jsonschema.json | 2 +- ...erServiceDiscoverEdgesResponse.schema.json | 2 +- .../api/gen/temp-openapi/apidocs.swagger.json | 4315 +++++++++++++++++ .../workflowcontract/v1/crafting_schema.pb.go | 18 +- app/controlplane/pkg/biz/referrer.go | 11 +- .../pkg/biz/referrer_integration_test.go | 22 +- app/controlplane/pkg/data/referrer.go | 7 +- 12 files changed, 4376 insertions(+), 28 deletions(-) create mode 100644 app/controlplane/api/gen/temp-openapi/apidocs.swagger.json diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index a2fe95f6e..124314243 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -45,6 +45,10 @@ type ReferrerServiceDiscoverEdgesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The referrers to find connections between. A digest alone does not identify a referrer, so // the kind travels with it. + // + // This is a set. A referrer may be listed more than once — a client that collected referrers + // from several attestations will have repeats — and is then reported at the position it first + // appeared in. At least two distinct referrers are required. Nodes []*ReferrerRef `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` // Restricts the answer to a project, the same way DiscoverPrivate does ProjectName string `protobuf:"bytes,2,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` @@ -108,7 +112,8 @@ func (x *ReferrerServiceDiscoverEdgesRequest) GetProjectVersion() string { // ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method type ReferrerServiceDiscoverEdgesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Each connection once, regardless of the direction it is stored in + // Each connection once, regardless of the direction it is stored in, and referred to by the + // first position its referrers were listed under in the request Edges []*ReferrerEdge `protobuf:"bytes,1,rep,name=edges,proto3" json:"edges,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index ab1eb4e6f..3617d34aa 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -57,6 +57,10 @@ service ReferrerService { message ReferrerServiceDiscoverEdgesRequest { // The referrers to find connections between. A digest alone does not identify a referrer, so // the kind travels with it. + // + // This is a set. A referrer may be listed more than once — a client that collected referrers + // from several attestations will have repeats — and is then reported at the position it first + // appeared in. At least two distinct referrers are required. repeated ReferrerRef nodes = 1 [(buf.validate.field).repeated = { min_items: 2 max_items: 1000 @@ -82,7 +86,8 @@ message ReferrerServiceDiscoverEdgesRequest { // ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method message ReferrerServiceDiscoverEdgesResponse { - // Each connection once, regardless of the direction it is stored in + // Each connection once, regardless of the direction it is stored in, and referred to by the + // first position its referrers were listed under in the request repeated ReferrerEdge edges = 1; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index 73d6f737f..f159dbdbd 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -12,6 +12,10 @@ export interface ReferrerServiceDiscoverEdgesRequest { /** * The referrers to find connections between. A digest alone does not identify a referrer, so * the kind travels with it. + * + * This is a set. A referrer may be listed more than once — a client that collected referrers + * from several attestations will have repeats — and is then reported at the position it first + * appeared in. At least two distinct referrers are required. */ nodes: ReferrerRef[]; /** Restricts the answer to a project, the same way DiscoverPrivate does */ @@ -22,7 +26,10 @@ export interface ReferrerServiceDiscoverEdgesRequest { /** ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method */ export interface ReferrerServiceDiscoverEdgesResponse { - /** Each connection once, regardless of the direction it is stored in */ + /** + * Each connection once, regardless of the direction it is stored in, and referred to by the + * first position its referrers were listed under in the request + */ edges: ReferrerEdge[]; } diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json index 2f4b7dc75..000850fa4 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json @@ -15,7 +15,7 @@ }, "properties": { "nodes": { - "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.", + "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.\n\n This is a set. A referrer may be listed more than once — a client that collected referrers\n from several attestations will have repeats — and is then reported at the position it first\n appeared in. At least two distinct referrers are required.", "items": { "$ref": "controlplane.v1.ReferrerRef.jsonschema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json index 61e2d3f51..420cf2504 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json @@ -15,7 +15,7 @@ }, "properties": { "nodes": { - "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.", + "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.\n\n This is a set. A referrer may be listed more than once — a client that collected referrers\n from several attestations will have repeats — and is then reported at the position it first\n appeared in. At least two distinct referrers are required.", "items": { "$ref": "controlplane.v1.ReferrerRef.schema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json index 522af8352..f0c4fe229 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json @@ -5,7 +5,7 @@ "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", "properties": { "edges": { - "description": "Each connection once, regardless of the direction it is stored in", + "description": "Each connection once, regardless of the direction it is stored in, and referred to by the\n first position its referrers were listed under in the request", "items": { "$ref": "controlplane.v1.ReferrerEdge.jsonschema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json index 26810c460..36b53c136 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json @@ -5,7 +5,7 @@ "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", "properties": { "edges": { - "description": "Each connection once, regardless of the direction it is stored in", + "description": "Each connection once, regardless of the direction it is stored in, and referred to by the\n first position its referrers were listed under in the request", "items": { "$ref": "controlplane.v1.ReferrerEdge.schema.json" }, diff --git a/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json b/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json new file mode 100644 index 000000000..d769d8a9b --- /dev/null +++ b/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json @@ -0,0 +1,4315 @@ +{ + "swagger": "2.0", + "info": { + "title": "Chainloop Controlplane API", + "termsOfService": "https://chainloop.dev/terms", + "version": "1.0", + "contact": { + "name": "Chainloop Support", + "url": "https://chainloop.dev", + "email": "support@chainloop.dev" + } + }, + "tags": [ + { + "name": "APITokenService" + }, + { + "name": "AttestationStateService" + }, + { + "name": "AuthService" + }, + { + "name": "CASBackendService" + }, + { + "name": "CASCredentialsService" + }, + { + "name": "CASRedirectService" + }, + { + "name": "ContextService" + }, + { + "name": "GroupService" + }, + { + "name": "IntegrationsService" + }, + { + "name": "OrgInvitationService" + }, + { + "name": "OrgMetricsService" + }, + { + "name": "OrganizationService" + }, + { + "name": "ProjectService" + }, + { + "name": "ReferrerService", + "description": "Referrer service for discovering referred content by digest" + }, + { + "name": "SigningService" + }, + { + "name": "StatusService" + }, + { + "name": "UserService" + }, + { + "name": "WorkflowService" + }, + { + "name": "WorkflowContractService" + }, + { + "name": "AttestationService" + }, + { + "name": "WorkflowRunService" + } + ], + "host": "cp.chainloop.dev", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/discover/edges": { + "post": { + "summary": "Discover the edges between known referrers", + "description": "Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges.", + "operationId": "ReferrerService_DiscoverEdges", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ReferrerServiceDiscoverEdgesResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "description": "Request to discover the edges between a set of referrers", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1ReferrerServiceDiscoverEdgesRequest" + } + } + ], + "tags": [ + "ReferrerService" + ], + "produces": [ + "application/json" + ] + } + }, + "/discover/{digest}": { + "get": { + "summary": "Discover private referrer", + "description": "Returns the referrer item for a given digest in the organizations of the logged-in user", + "operationId": "ReferrerService_DiscoverPrivate", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ReferrerServiceDiscoverPrivateResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "digest", + "description": "Digest is the unique identifier of the referrer to discover", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "kind", + "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\nUsed to filter and resolve ambiguities", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "pagination.cursor", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "pagination.limit", + "description": "Limit pagination to 100", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "project_name", + "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n(project-wide filter) or together with project_version (project + version filter).", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "project_version", + "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\nRequires project_name, since a version name is unique only within a project.", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "ReferrerService" + ], + "produces": [ + "application/json" + ] + } + }, + "/infoz": { + "get": { + "operationId": "StatusService_Infoz", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1InfozResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "tags": [ + "StatusService" + ] + } + }, + "/statusz": { + "get": { + "operationId": "StatusService_Statusz", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1StatuszResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "readiness", + "description": "Parameter that can be used by readiness probes\nThe main difference is that readiness probes will take into account that all\ndependent services are up and ready", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "tags": [ + "StatusService" + ] + } + } + }, + "definitions": { + "APITokenServiceCreateResponseAPITokenFull": { + "type": "object", + "properties": { + "item": { + "$ref": "#/definitions/v1APITokenItem" + }, + "jwt": { + "type": "string" + } + } + }, + "APITokenServiceListRequestScope": { + "type": "string", + "enum": [ + "SCOPE_UNSPECIFIED", + "SCOPE_PROJECT", + "SCOPE_GLOBAL" + ], + "default": "SCOPE_UNSPECIFIED" + }, + "APITokenServiceListRequestStatusFilter": { + "type": "string", + "enum": [ + "STATUS_FILTER_ACTIVE", + "STATUS_FILTER_REVOKED", + "STATUS_FILTER_ALL" + ], + "default": "STATUS_FILTER_ACTIVE", + "description": "buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX\nSTATUS_FILTER_ACTIVE = 0 is intentional: active tokens are the default,\nmatching proto3 zero-value semantics and preserving backward compatibility.\n\n - STATUS_FILTER_ACTIVE: Only active (non-revoked) tokens. This is the default.\n - STATUS_FILTER_REVOKED: Only revoked tokens.\n - STATUS_FILTER_ALL: All tokens regardless of revocation status." + }, + "AttestationAuth": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/AuthAuthType" + }, + "id": { + "type": "string", + "title": "Identifier of the authentication (user ID, token ID, etc.)" + } + } + }, + "AttestationCASBackend": { + "type": "object", + "properties": { + "cas_backend_id": { + "type": "string", + "title": "UUID of the CAS backend" + }, + "cas_backend_name": { + "type": "string", + "title": "Name of the CAS backend" + }, + "fallback": { + "type": "boolean", + "title": "Whether this is a fallback backend" + } + } + }, + "AttestationItemEnvVariable": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "AttestationItemPolicyEvaluationStatus": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "bypassed": { + "type": "boolean" + }, + "blocked": { + "type": "boolean" + }, + "has_violations": { + "type": "boolean" + }, + "has_gated_violations": { + "type": "boolean" + }, + "evaluations_count": { + "type": "integer", + "format": "int32", + "description": "Deprecated: use summary.total instead." + }, + "violations_count": { + "type": "integer", + "format": "int32", + "description": "Deprecated: use summary.violated instead." + }, + "summary": { + "$ref": "#/definitions/v1PolicyStatusSummary", + "description": "Canonical categorical status + counters. Single source of truth for UI\nconsumers — consumers that re-derive from the raw bools above tend to\ndisagree on semantics, especially around gating and bypass." + } + } + }, + "AttestationServiceCancelRequestTriggerType": { + "type": "string", + "enum": [ + "TRIGGER_TYPE_UNSPECIFIED", + "TRIGGER_TYPE_FAILURE", + "TRIGGER_TYPE_CANCELLATION" + ], + "default": "TRIGGER_TYPE_UNSPECIFIED" + }, + "AuthAuthType": { + "type": "string", + "enum": [ + "AUTH_TYPE_UNSPECIFIED", + "AUTH_TYPE_USER", + "AUTH_TYPE_API_TOKEN", + "AUTH_TYPE_FEDERATED" + ], + "default": "AUTH_TYPE_UNSPECIFIED" + }, + "CASBackendItemLimits": { + "type": "object", + "properties": { + "max_bytes": { + "type": "string", + "format": "int64", + "title": "Max number of bytes allowed to be stored in this backend" + } + } + }, + "CASBackendItemValidationStatus": { + "type": "string", + "enum": [ + "VALIDATION_STATUS_UNSPECIFIED", + "VALIDATION_STATUS_OK", + "VALIDATION_STATUS_INVALID" + ], + "default": "VALIDATION_STATUS_UNSPECIFIED" + }, + "CASCredentialsServiceGetRequestRole": { + "type": "string", + "enum": [ + "ROLE_UNSPECIFIED", + "ROLE_DOWNLOADER", + "ROLE_UPLOADER" + ], + "default": "ROLE_UNSPECIFIED" + }, + "CommitCommitVerification": { + "type": "object", + "properties": { + "attempted": { + "type": "boolean", + "title": "Whether verification was attempted" + }, + "status": { + "$ref": "#/definitions/CommitVerificationVerificationStatus", + "title": "Verification status" + }, + "reason": { + "type": "string", + "title": "Human-readable reason for the status" + }, + "platform": { + "type": "string", + "title": "Platform that performed the verification (e.g., \"github\", \"gitlab\")" + }, + "key_id": { + "type": "string", + "title": "Optional: The signing key ID if verified" + }, + "signature_algorithm": { + "type": "string", + "title": "Optional: The signature algorithm used" + } + } + }, + "CommitRemote": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "CommitVerificationVerificationStatus": { + "type": "string", + "enum": [ + "unspecified", + "verified", + "unverified", + "unavailable", + "not_applicable" + ], + "default": "unspecified", + "description": "- verified: Successfully verified by platform\n - unverified: Platform checked but signature is invalid/unverified\n - unavailable: Verification could not be performed (no API access, network error, etc.)\n - not_applicable: Platform doesn't support verification or no commit signature present", + "title": "buf:lint:ignore ENUM_VALUE_UPPER_SNAKE_CASE\nbuf:lint:ignore ENUM_VALUE_PREFIX\nbuf:lint:ignore ENUM_ZERO_VALUE_SUFFIX" + }, + "CraftingSchemaRunner": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/RunnerRunnerType" + } + } + }, + "DailyRunsCountResponseTotalByDay": { + "type": "object", + "properties": { + "date": { + "type": "string", + "title": "string format: \"YYYY-MM-DD\"" + }, + "metrics": { + "$ref": "#/definitions/v1MetricsStatusCount" + } + } + }, + "GroupServiceListProjectsResponseProjectInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "Unique identifier of the project" + }, + "name": { + "type": "string", + "title": "Name of the project" + }, + "description": { + "type": "string", + "title": "Description of the project" + }, + "role": { + "$ref": "#/definitions/v1ProjectMemberRole", + "title": "Role of the group in the project (admin or viewer)" + }, + "latest_version_id": { + "type": "string", + "title": "The latest version ID of the project, if available" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the membership was created" + } + }, + "title": "ProjectInfo represents detailed information about a project that a group is a member of" + }, + "MaterialArtifact": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" + }, + "name": { + "type": "string", + "title": "filename, use for record purposes" + }, + "digest": { + "type": "string", + "title": "the digest is enough to retrieve the artifact since it's stored in a CAS\nwhich also has annotated the fileName" + }, + "is_subject": { + "type": "boolean" + }, + "content": { + "type": "string", + "format": "byte", + "title": "Inline content of the artifact.\nThis is optional and is used for small artifacts that can be stored inline in the attestation" + } + } + }, + "MaterialContainerImage": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" + }, + "name": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "is_subject": { + "type": "boolean" + }, + "tag": { + "type": "string", + "title": "provided tag" + }, + "signature_digest": { + "type": "string", + "title": "Digest of the found signature for the image" + }, + "signature_provider": { + "type": "string", + "title": "The provider in charge of the signature" + }, + "signature": { + "type": "string", + "title": "Base64 encoded signature payload, aka the OCI Signature Manifest" + }, + "has_latest_tag": { + "type": "boolean", + "description": "Indicates if the image has the latest tag. The image being checked\nmight not explicitly have the latest tag, but it could also be tagged\nwith the latest tag." + } + } + }, + "MaterialKeyVal": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" + }, + "value": { + "type": "string" + }, + "digest": { + "type": "string" + } + } + }, + "MaterialMaterialType": { + "type": "string", + "enum": [ + "MATERIAL_TYPE_UNSPECIFIED", + "STRING", + "CONTAINER_IMAGE", + "ARTIFACT", + "SBOM_CYCLONEDX_JSON", + "SBOM_SPDX_JSON", + "JUNIT_XML", + "OPENVEX", + "HELM_CHART", + "SARIF", + "EVIDENCE", + "ATTESTATION", + "CSAF_VEX", + "CSAF_INFORMATIONAL_ADVISORY", + "CSAF_SECURITY_ADVISORY", + "CSAF_SECURITY_INCIDENT_RESPONSE", + "GITLAB_SECURITY_REPORT", + "ZAP_DAST_ZIP", + "BLACKDUCK_SCA_JSON", + "TWISTCLI_SCAN_JSON", + "GHAS_CODE_SCAN", + "GHAS_SECRET_SCAN", + "GHAS_DEPENDENCY_SCAN", + "JACOCO_XML", + "SLSA_PROVENANCE", + "CHAINLOOP_RUNNER_CONTEXT", + "CHAINLOOP_PR_INFO", + "GITLEAKS_JSON", + "CHAINLOOP_AI_AGENT_CONFIG", + "CHAINLOOP_AI_CODING_SESSION", + "OPENAPI_SPEC", + "ASYNCAPI_SPEC", + "GRAPHQL_SPEC", + "YELP_DETECT_SECRETS_BASELINE", + "SYSINTERNALS_SIGCHECK", + "SYSINTERNALS_ACCESSCHK", + "CERTCC_DRANZER", + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML", + "CHECKMARX_JSON", + "OVERSECURED_JSON", + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" + ], + "default": "MATERIAL_TYPE_UNSPECIFIED", + "title": "- OPENVEX: https://github.com/openvex/spec\n - HELM_CHART: Static analysis output format\nhttps://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md\n - EVIDENCE: Pieces of evidences represent generic, additional context that don't fit\ninto one of the well known material types. For example, a custom approval report (in json), ...\n - ATTESTATION: Chainloop attestation coming from a different workflow.\n - CSAF_VEX: https://docs.oasis-open.org/csaf/csaf/v2.0/cs03/csaf-v2.0-cs03.html\n - GITLAB_SECURITY_REPORT: Gitlab Application Security Reports https://docs.gitlab.com/ee/user/application_security/\n - TWISTCLI_SCAN_JSON: Twistcli scan output in json format: https://docs.prismacloud.io/en/compute-edition/30/admin-guide/tools/twistcli-scan-images\n - GHAS_CODE_SCAN: GitHub Advanced Security API reports\nhttps://docs.github.com/en/rest/code-scanning/code-scanning?apiVersion=2022-11-28\n - GHAS_SECRET_SCAN: https://docs.github.com/en/rest/secret-scanning/secret-scanning?apiVersion=2022-11-28\n - GHAS_DEPENDENCY_SCAN: https://docs.github.com/en/rest/dependabot/alerts?apiVersion=2022-11-28\n - JACOCO_XML: Jacoco coverage reports https://www.jacoco.org/userdoc/importexport.html\n - SLSA_PROVENANCE: SLSA Provenance in the form of Sigstore Bundle\nhttps://slsa.dev/spec/v1.0/provenance\nhttps://docs.sigstore.dev/about/bundle/\n - CHAINLOOP_RUNNER_CONTEXT: The Chainloop CLI plugin for runner context\n - CHAINLOOP_PR_INFO: Pull Request / Merge Request metadata collected automatically during attestation\n - GITLEAKS_JSON: Gitleaks json report https://github.com/gitleaks/gitleaks/blob/master/README.md#reporting\n - CHAINLOOP_AI_AGENT_CONFIG: AI agent configuration collected automatically during attestation\n - CHAINLOOP_AI_CODING_SESSION: AI coding session telemetry collected during attestation\n - OPENAPI_SPEC: OpenAPI specification (v3.0, v3.1)\nhttps://spec.openapis.org/oas/latest.html\n - ASYNCAPI_SPEC: AsyncAPI specification (v2.6, v3.0)\nhttps://www.asyncapi.com/docs/reference/specification/latest\n - GRAPHQL_SPEC: GraphQL SDL schema\nhttps://spec.graphql.org/\n - YELP_DETECT_SECRETS_BASELINE: detect-secrets baseline file https://github.com/Yelp/detect-secrets\n - SYSINTERNALS_SIGCHECK: Sysinternals sigcheck output in CSV format\nhttps://learn.microsoft.com/en-us/sysinternals/downloads/sigcheck\n - SYSINTERNALS_ACCESSCHK: Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk\n - CERTCC_DRANZER: CERT/CC dranzer ActiveX/COM control test report (plain text): a single\nreport or an archive (zip or tar.gz) holding the per-mode reports of one\nrun (-b, -p, -s, -t) https://github.com/CERTCC/dranzer\n - OSSF_SCORECARD_JSON: OpenSSF Scorecard result in JSON format\nhttps://github.com/ossf/scorecard\n - RADAMSA_REPORT: radamsa -M metadata log, one record per generated iteration\nhttps://gitlab.com/akihe/radamsa\n - RADAMSA_CRASHES: radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip)\n - TRUFFLEHOG_JSON: TruffleHog secret scanning report in JSONL format (one JSON finding per line)\nhttps://github.com/trufflesecurity/trufflehog\n - COBERTURA_XML: Cobertura code coverage report in XML format\nhttps://github.com/cobertura/cobertura\n - CHECKMARX_JSON: Checkmarx One native JSON report (ScanResultsCollection)\nhttps://github.com/Checkmarx/ast-cli/blob/main/internal/wrappers/results-json.go\n - OVERSECURED_JSON: Oversecured mobile (Android/iOS) scan report, whole-scan JSON export\nhttps://docs.oversecured.com/docs/guide-exporting-reports\n - PITEST_XML: PIT mutation testing report in its native XML format (mutations.xml)\nhttps://pitest.org/\n - CHAINLOOP_AI_SECURITY_CONTEXT: Chainloop AI security context: vulnerability fingerprints mined from a\nrepository's fix history, the attack surfaces they share, ranked risks,\nand byte-verifiable evidence anchors backing each claim" + }, + "MaterialSBOMArtifact": { + "type": "object", + "properties": { + "artifact": { + "$ref": "#/definitions/MaterialArtifact", + "title": "The actual SBOM artifact" + }, + "main_component": { + "$ref": "#/definitions/SBOMArtifactMainComponent", + "title": "The Main component if any the SBOM is related to" + } + } + }, + "MaterialSelectorMatchMode": { + "type": "string", + "enum": [ + "UNSPECIFIED", + "EXACT", + "PREFIX" + ], + "default": "UNSPECIFIED", + "title": "Values are intentionally unprefixed for contract-author usability\n(\"match_mode: PREFIX\" reads better than \"MATCH_MODE_PREFIX\"), matching the\nother unprefixed enums in this file (RunnerType, MaterialType).\nbuf:lint:ignore ENUM_ZERO_VALUE_SUFFIX" + }, + "OrgItemPolicyViolationBlockingStrategy": { + "type": "string", + "enum": [ + "POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED", + "POLICY_VIOLATION_BLOCKING_STRATEGY_BLOCK", + "POLICY_VIOLATION_BLOCKING_STRATEGY_ADVISORY" + ], + "default": "POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED" + }, + "PolicyAttachmentMaterialSelector": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "material name" + }, + "match_mode": { + "$ref": "#/definitions/MaterialSelectorMatchMode", + "description": "How `name` is matched against a material's name. Defaults to exact match\n(UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\nPREFIX to target a set of materials sharing a name prefix (e.g. an\narchive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." + } + } + }, + "PolicyEvaluationRawResult": { + "type": "object", + "properties": { + "input": { + "type": "string", + "format": "byte", + "title": "Input data provided to the policy engine" + }, + "output": { + "type": "string", + "format": "byte", + "title": "Output data returned by the policy engine" + } + } + }, + "PolicyEvaluationReference": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "org_name": { + "type": "string" + } + } + }, + "PolicyEvaluationsRefReason": { + "type": "string", + "enum": [ + "REASON_UNSPECIFIED", + "REASON_TOO_LARGE", + "REASON_UNAVAILABLE" + ], + "default": "REASON_UNSPECIFIED", + "title": "- REASON_TOO_LARGE: The bundle exceeds the maximum size the server inlines in a response\n - REASON_UNAVAILABLE: The bundle could not be resolved from CAS" + }, + "PolicyGroupPolicyGroupPolicies": { + "type": "object", + "properties": { + "materials": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyGroupMaterial" + } + }, + "attestation": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyAttachment" + } + } + } + }, + "PolicyGroupPolicyGroupSpec": { + "type": "object", + "properties": { + "policies": { + "$ref": "#/definitions/PolicyGroupPolicyGroupPolicies" + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyInput" + } + } + } + }, + "ProjectServiceListResponseProjectItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "Unique identifier of the project" + }, + "name": { + "type": "string", + "title": "Name of the project" + }, + "description": { + "type": "string", + "title": "Description of the project" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the project was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the project was last updated" + }, + "can_create_workflow": { + "type": "boolean", + "description": "Whether the caller may create a workflow in this project. The listing is\nfiltered by what the caller can see, which is not the same as what they\ncan write to: a project viewer can read a project but not add a workflow\nto it. This is resolved server-side because a caller can hold several\nroles on the same project, directly and through products, and any one of\nthem granting the permission is enough." + } + }, + "title": "ProjectItem represents a project of the organization" + }, + "RawBodyFormat": { + "type": "string", + "enum": [ + "FORMAT_UNSPECIFIED", + "FORMAT_JSON", + "FORMAT_YAML", + "FORMAT_CUE" + ], + "default": "FORMAT_UNSPECIFIED" + }, + "RunnerRunnerType": { + "type": "string", + "enum": [ + "RUNNER_TYPE_UNSPECIFIED", + "GITHUB_ACTION", + "GITLAB_PIPELINE", + "AZURE_PIPELINE", + "JENKINS_JOB", + "CIRCLECI_BUILD", + "DAGGER_PIPELINE", + "TEAMCITY_PIPELINE", + "TEKTON_PIPELINE", + "CHAINLOOP_SANDBOX" + ], + "default": "RUNNER_TYPE_UNSPECIFIED" + }, + "SBOMArtifactMainComponent": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "The name of the main component" + }, + "version": { + "type": "string", + "title": "The version of the main component" + }, + "kind": { + "type": "string", + "title": "The kind of the main component" + } + }, + "title": "The main component of the SBOM" + }, + "TopWorkflowsByRunsCountResponseTotalByStatus": { + "type": "object", + "properties": { + "workflow": { + "$ref": "#/definitions/v1WorkflowItem" + }, + "runs_total_by_status": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1MetricsStatusCount" + } + } + } + }, + "WorkflowContractServiceApplyResponseApplyStatus": { + "type": "string", + "enum": [ + "APPLY_STATUS_UNSPECIFIED", + "APPLY_STATUS_CREATED", + "APPLY_STATUS_UPDATED", + "APPLY_STATUS_UNCHANGED" + ], + "default": "APPLY_STATUS_UNSPECIFIED", + "description": "- APPLY_STATUS_CREATED: The resource did not exist and was created\n - APPLY_STATUS_UPDATED: The resource existed and its content changed\n - APPLY_STATUS_UNCHANGED: The resource existed and its content was identical", + "title": "ApplyStatus describes how the applied resource changed" + }, + "WorkflowContractVersionItemRawBody": { + "type": "object", + "properties": { + "body": { + "type": "string", + "format": "byte" + }, + "format": { + "$ref": "#/definitions/RawBodyFormat" + } + } + }, + "WorkflowRunServiceViewResponseVerificationResult": { + "type": "object", + "properties": { + "verified": { + "type": "boolean", + "title": "if it can be verified this will hold the result of the verification" + }, + "failure_reason": { + "type": "string", + "title": "why it couldn't be verified, or the failure reason" + } + } + }, + "attestationv1PolicyEvaluation": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "The policy name from the policy spec" + }, + "material_name": { + "type": "string" + }, + "body": { + "type": "string", + "title": "the body of the policy. This field will be empty if there is a FQDN reference to the policy" + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Base64 representation of run scripts. It might be empty if there is a FQDN reference to the policy" + }, + "reference_digest": { + "type": "string", + "title": "fully qualified reference to the policy\ni.e\nhttp://my-domain.com/foo.yaml\nfile://foo.yaml\nchainloop://my-provider.com/foo@sha256:1234\nNOTE: embedded policies will not have a reference\nDeprecated: use policy_reference instead" + }, + "reference_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyEvaluationViolation" + }, + "title": "The policy violations, if any" + }, + "with": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "arguments, as they come from the policy attachment" + }, + "type": { + "$ref": "#/definitions/MaterialMaterialType", + "title": "material type, if any, of the evaluated policy" + }, + "skipped": { + "type": "boolean", + "title": "whether this evaluation was skipped or not (because of an invalid input, for example)" + }, + "skip_reasons": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Evaluation messages, intended to communicate evaluation errors (invalid input)" + }, + "policy_reference": { + "$ref": "#/definitions/PolicyEvaluationReference", + "title": "Group this evaluated policy belongs to, if any" + }, + "group_reference": { + "$ref": "#/definitions/PolicyEvaluationReference" + }, + "requirements": { + "type": "array", + "items": { + "type": "string" + }, + "title": "List of requirements this policy contributes to satisfy" + }, + "raw_results": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/PolicyEvaluationRawResult" + }, + "description": "Raw inputs and outputs from the policy engine, preserved for debugging." + }, + "gate": { + "type": "boolean", + "title": "Whether the policy evaluation result should block the attestation (inherited from the policy attachment)" + }, + "runtime_input_overrides": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of policy inputs whose values were supplied or overridden at runtime\n(e.g. via --policy-input-from-file) rather than coming solely from the\ncontract. The effective merged values live in `with`; this only records\nwhich input names were touched at runtime. Empty means no runtime override." + } + }, + "title": "A policy executed against an attestation or material" + }, + "attestationv1ProjectVersion": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "prerelease": { + "type": "boolean", + "title": "if it's pre-release" + }, + "mark_as_released": { + "type": "boolean" + } + } + }, + "controlplanev1PolicyEvaluation": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "material_name": { + "type": "string" + }, + "body": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "with": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyViolation" + } + }, + "policy_reference": { + "$ref": "#/definitions/v1PolicyReference" + }, + "skipped": { + "type": "boolean" + }, + "skip_reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "requirements": { + "type": "array", + "items": { + "type": "string" + } + }, + "group_reference": { + "$ref": "#/definitions/v1PolicyReference" + }, + "gate": { + "type": "boolean" + } + } + }, + "controlplanev1ProjectVersion": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "released_at": { + "type": "string", + "format": "date-time", + "title": "when it was marked as released" + }, + "latest": { + "type": "boolean" + } + } + }, + "googlerpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "protobufNullValue": { + "type": "string", + "enum": [ + "NULL_VALUE" + ], + "default": "NULL_VALUE", + "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\nThe JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." + }, + "v1APITokenItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "organization_name": { + "type": "string" + }, + "scoped_entity": { + "$ref": "#/definitions/v1ScopedEntity", + "title": "wether the token is scoped to an entity in the organization" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time" + } + } + }, + "v1APITokenServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/APITokenServiceCreateResponseAPITokenFull" + } + } + }, + "v1APITokenServiceListResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1APITokenItem" + } + } + } + }, + "v1APITokenServiceRevokeResponse": { + "type": "object" + }, + "v1Attestation": { + "type": "object", + "properties": { + "initialized_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "workflow": { + "$ref": "#/definitions/v1WorkflowMetadata" + }, + "materials": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1AttestationMaterial" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Annotations for the attestation" + }, + "env_vars": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "List of env variables" + }, + "runner_url": { + "type": "string" + }, + "runner_type": { + "$ref": "#/definitions/RunnerRunnerType" + }, + "head": { + "$ref": "#/definitions/v1Commit", + "title": "Head Commit of the environment where the attestation was executed (optional)" + }, + "policy_evaluations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/attestationv1PolicyEvaluation" + }, + "title": "Policies that materials in this attestation were validated against" + }, + "block_on_policy_violation": { + "type": "boolean", + "title": "fail the attestation if policy evaluation fails" + }, + "bypass_policy_check": { + "type": "boolean", + "title": "bypass policy check" + }, + "signing_options": { + "$ref": "#/definitions/v1AttestationSigningOptions", + "title": "Signing options" + }, + "runner_environment": { + "$ref": "#/definitions/v1RunnerEnvironment", + "title": "Runner environment in which the attestation was crafted" + }, + "auth": { + "$ref": "#/definitions/AttestationAuth", + "title": "Authentication information used during attestation" + }, + "policies_allowed_hostnames": { + "type": "array", + "items": { + "type": "string" + }, + "title": "array of hostnames that are allowed to be used in the policies" + }, + "cas_backend": { + "$ref": "#/definitions/AttestationCASBackend", + "title": "CAS backend information used during attestation" + }, + "skip_runner_env_vars": { + "type": "boolean", + "description": "skip storing the environment variables automatically discovered by the CI runner.\nThe contract's env_allow_list is not affected by this flag." + } + } + }, + "v1AttestationItem": { + "type": "object", + "properties": { + "envelope": { + "type": "string", + "format": "byte", + "title": "encoded DSEE envelope" + }, + "bundle": { + "type": "string", + "format": "byte", + "title": "Attestation bundle" + }, + "digest_in_cas_backend": { + "type": "string", + "title": "sha256sum of the bundle containing the envelope, or the envelope in old attestations\nused as a key in the CAS backend" + }, + "env_vars": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/AttestationItemEnvVariable" + }, + "title": "denormalized envelope/statement content" + }, + "materials": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1AttestationItemMaterial" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "policy_evaluations": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1PolicyEvaluations" + } + }, + "policy_evaluation_status": { + "$ref": "#/definitions/AttestationItemPolicyEvaluationStatus" + }, + "policy_evaluations_ref": { + "$ref": "#/definitions/v1PolicyEvaluationsRef", + "description": "Reference to the policy-evaluation bundle in the CAS backend. Populated\nwhenever the attestation carries a bundle, whether or not the evaluations\nwere also inlined in policy_evaluations: see its \"inlined\" field. Fetch the\nbundle by digest to inspect the full set of evaluations.\nCounters and status in policy_evaluation_status remain complete either way." + } + } + }, + "v1AttestationItemMaterial": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string", + "description": "Deprecated: use raw_value instead. This field cannot represent binary content." + }, + "filename": { + "type": "string", + "title": "filename of the artifact that was either uploaded or injected inline in \"value\"" + }, + "type": { + "type": "string", + "title": "Material type, i.e ARTIFACT" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "tag": { + "type": "string", + "title": "in the case of a container image, the tag of the attested image" + }, + "hash": { + "type": "string" + }, + "uploaded_to_cas": { + "type": "boolean", + "title": "it's been uploaded to an actual CAS backend" + }, + "embedded_inline": { + "type": "boolean", + "title": "the content instead if inline" + }, + "raw_value": { + "type": "string", + "format": "byte", + "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\nFor string materials, contains the UTF-8 encoded value.\nFor non-inline materials (container images, uploaded artifacts), contains\nthe filename or image reference as UTF-8.\nClients should prefer this field over the deprecated string value field." + } + } + }, + "v1AttestationMaterial": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "string": { + "$ref": "#/definitions/MaterialKeyVal" + }, + "container_image": { + "$ref": "#/definitions/MaterialContainerImage" + }, + "artifact": { + "$ref": "#/definitions/MaterialArtifact" + }, + "sbom_artifact": { + "$ref": "#/definitions/MaterialSBOMArtifact" + }, + "added_at": { + "type": "string", + "format": "date-time" + }, + "material_type": { + "$ref": "#/definitions/MaterialMaterialType" + }, + "uploaded_to_cas": { + "type": "boolean", + "title": "Whether the material has been uploaded to the CAS" + }, + "inline_cas": { + "type": "boolean", + "title": "If the material content has been injected inline in the attestation\nleveraging a form of inline CAS" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Annotations for the material" + }, + "output": { + "type": "boolean" + }, + "required": { + "type": "boolean" + } + } + }, + "v1AttestationPhase": { + "type": "string", + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "PUSH" + ], + "default": "ATTESTATION_PHASE_UNSPECIFIED", + "description": "Lifecycle phases that control when an attestation policy is evaluated.\nOnly applicable to policies with kind ATTESTATION in PolicySpecV2." + }, + "v1AttestationServiceCancelResponse": { + "type": "object" + }, + "v1AttestationServiceGetContractResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationServiceGetContractResponseResult" + } + } + }, + "v1AttestationServiceGetContractResponseResult": { + "type": "object", + "properties": { + "workflow": { + "$ref": "#/definitions/v1WorkflowItem" + }, + "contract": { + "$ref": "#/definitions/v1WorkflowContractVersionItem" + } + } + }, + "v1AttestationServiceGetPolicyGroupResponse": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/v1PolicyGroup" + }, + "reference": { + "$ref": "#/definitions/v1RemotePolicyReference", + "title": "FQDN of the policy in the provider" + } + } + }, + "v1AttestationServiceGetPolicyResponse": { + "type": "object", + "properties": { + "policy": { + "$ref": "#/definitions/v1Policy" + }, + "reference": { + "$ref": "#/definitions/v1RemotePolicyReference", + "title": "FQDN of the policy in the provider" + } + } + }, + "v1AttestationServiceGetUploadCredsResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationServiceGetUploadCredsResponseResult" + } + } + }, + "v1AttestationServiceGetUploadCredsResponseResult": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "backend": { + "$ref": "#/definitions/v1CASBackendItem" + } + } + }, + "v1AttestationServiceInitResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationServiceInitResponseResult" + } + } + }, + "v1AttestationServiceInitResponseResult": { + "type": "object", + "properties": { + "workflow_run": { + "$ref": "#/definitions/v1WorkflowRunItem" + }, + "organization": { + "type": "string", + "title": "organization name" + }, + "block_on_policy_violation": { + "type": "boolean", + "title": "fail the attestation if there is a violation in any policy" + }, + "signing_options": { + "$ref": "#/definitions/v1AttestationServiceInitResponseSigningOptions", + "title": "Signing options" + }, + "policies_allowed_hostnames": { + "type": "array", + "items": { + "type": "string" + }, + "title": "array of hostnames that are allowed to be used in the policies" + }, + "ui_dashboard_url": { + "type": "string", + "title": "URL pointing to the web dashboard. It might be empty if not available" + }, + "enable_ai_agent_collector": { + "type": "boolean", + "title": "Whether AI agent config collection is enabled at the org level" + }, + "skip_runner_env_vars": { + "type": "boolean", + "title": "Whether to skip storing the environment variables automatically discovered by the CI runner in the attestation" + } + } + }, + "v1AttestationServiceInitResponseSigningOptions": { + "type": "object", + "properties": { + "timestamp_authority_url": { + "type": "string", + "title": "TSA service to be used for signing" + }, + "signing_ca": { + "type": "string", + "title": "If set, the attestation wil be signed with ephemeral certificates issued by this CA" + } + } + }, + "v1AttestationServiceStoreResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationServiceStoreResponseResult" + } + } + }, + "v1AttestationServiceStoreResponseResult": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "title": "attestation digest" + } + } + }, + "v1AttestationSigningOptions": { + "type": "object", + "properties": { + "timestamp_authority_url": { + "type": "string", + "title": "TSA URL" + }, + "signing_ca": { + "type": "string", + "title": "Signing CA to be used for signing" + } + } + }, + "v1AttestationStateServiceInitializedResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationStateServiceInitializedResponseResult" + } + } + }, + "v1AttestationStateServiceInitializedResponseResult": { + "type": "object", + "properties": { + "initialized": { + "type": "boolean" + } + } + }, + "v1AttestationStateServiceReadResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1AttestationStateServiceReadResponseResult" + } + } + }, + "v1AttestationStateServiceReadResponseResult": { + "type": "object", + "properties": { + "attestation_state": { + "$ref": "#/definitions/v1CraftingState" + }, + "digest": { + "type": "string", + "title": "digest of the attestation state to implement Optimistic Concurrency Control" + } + } + }, + "v1AttestationStateServiceResetResponse": { + "type": "object" + }, + "v1AttestationStateServiceSaveResponse": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "title": "digest of the newly saved attestation state for Optimistic Concurrency Control" + } + } + }, + "v1AuthServiceDeleteAccountResponse": { + "type": "object", + "description": "AuthServiceDeleteAccountResponse is the response for the DeleteAccount method." + }, + "v1AutoMatch": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "path to a policy script. It might consist of a URI reference\ndeprecated: use ref instead" + }, + "embedded": { + "type": "string", + "title": "embedded source code (only Rego supported currently)" + }, + "ref": { + "type": "string", + "title": "generic reference for file:// and http(s):// schemes" + } + }, + "title": "Auto-matching policy specification" + }, + "v1CASBackendItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "location": { + "type": "string", + "title": "e.g. myregistry.io/myrepo s3 bucket and so on" + }, + "description": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "validated_at": { + "type": "string", + "format": "date-time" + }, + "validation_status": { + "$ref": "#/definitions/CASBackendItemValidationStatus" + }, + "provider": { + "type": "string", + "description": "OCI, S3, ..." + }, + "default": { + "type": "boolean", + "title": "Wether it's the default backend in the organization" + }, + "limits": { + "$ref": "#/definitions/CASBackendItemLimits", + "title": "Limits for this backend" + }, + "is_inline": { + "type": "boolean", + "title": "Is it an inline backend?\ninline means that the content is stored in the attestation itself" + }, + "validation_error": { + "type": "string", + "title": "Error message if validation failed" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "fallback": { + "type": "boolean", + "title": "Wether it's the fallback backend in the organization" + }, + "is_managed": { + "type": "boolean", + "title": "Whether this backend is provisioned and operated by Chainloop using" + } + } + }, + "v1CASBackendServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1CASBackendItem" + } + } + }, + "v1CASBackendServiceDeleteResponse": { + "type": "object" + }, + "v1CASBackendServiceListResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1CASBackendItem" + } + } + } + }, + "v1CASBackendServiceRevalidateResponse": { + "type": "object" + }, + "v1CASBackendServiceUpdateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1CASBackendItem" + } + } + }, + "v1CASCredentialsServiceGetResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1CASCredentialsServiceGetResponseResult" + } + } + }, + "v1CASCredentialsServiceGetResponseResult": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "backend": { + "$ref": "#/definitions/v1CASBackendItem" + } + } + }, + "v1CertificateChain": { + "type": "object", + "properties": { + "certificates": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The PEM-encoded certificate chain, ordered from leaf to intermediate to root as applicable." + } + } + }, + "v1Commit": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "author_email": { + "type": "string", + "title": "Commit authors might not include email i.e \"Flux \u003c\u003e\"" + }, + "author_name": { + "type": "string" + }, + "message": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "remotes": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/CommitRemote" + } + }, + "signature": { + "type": "string" + }, + "platform_verification": { + "$ref": "#/definitions/CommitCommitVerification", + "title": "Platform verification information (GitHub/GitLab signature verification)" + } + } + }, + "v1ContextServiceCurrentResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1ContextServiceCurrentResponseResult" + } + } + }, + "v1ContextServiceCurrentResponseResult": { + "type": "object", + "properties": { + "current_user": { + "$ref": "#/definitions/v1User" + }, + "current_membership": { + "$ref": "#/definitions/v1OrgMembershipItem" + }, + "current_cas_backend": { + "$ref": "#/definitions/v1CASBackendItem" + } + } + }, + "v1CraftingSchema": { + "type": "object", + "properties": { + "schema_version": { + "type": "string", + "title": "Version of the schema, do not confuse with the revision of the content" + }, + "materials": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1CraftingSchemaMaterial" + } + }, + "env_allow_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "$ref": "#/definitions/CraftingSchemaRunner" + }, + "annotations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/workflowcontractv1Annotation" + }, + "title": "List of annotations that can be used to add metadata to the attestation\nthis metadata can be used later on by the integrations engine to filter and interpolate data\nIt works in addition to the annotations defined in the materials and the runner" + }, + "policies": { + "$ref": "#/definitions/v1Policies", + "title": "Policies to apply to this schema" + }, + "policy_groups": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyGroupAttachment" + }, + "title": "Policy groups to apply to this schema" + } + }, + "title": "Schema definition provided by the user to the tool\nthat defines the schema of the workflowRun" + }, + "v1CraftingSchemaMaterial": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/MaterialMaterialType" + }, + "name": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "output": { + "type": "boolean", + "title": "If a material is set as output it will get added to the subject in the statement" + }, + "annotations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/workflowcontractv1Annotation" + }, + "title": "List of annotations that can be used to add metadata to the material\nthis metadata can be used later on by the integrations engine to filter and interpolate data" + }, + "skip_upload": { + "type": "boolean", + "title": "If true, skip uploading the material to CAS (only record metadata)\nDefaults to false (material will be uploaded)" + }, + "group": { + "type": "string", + "description": "Choke group: materials sharing the same non-empty group value form an\n\"at least one of\" set, meaning at least one member must be present in the\nattestation. Materials without a group keep their individual required/optional behavior." + } + } + }, + "v1CraftingSchemaV2": { + "type": "object", + "properties": { + "api_version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/v1Metadata" + }, + "spec": { + "$ref": "#/definitions/v1CraftingSchemaV2Spec" + } + }, + "title": "Schema definition provided by the user to the tool\nthat defines the schema of the workflowRun" + }, + "v1CraftingSchemaV2Spec": { + "type": "object", + "properties": { + "materials": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1CraftingSchemaMaterial" + }, + "title": "Materials that are expected to be present in the attestation" + }, + "env_allow_list": { + "type": "array", + "items": { + "type": "string" + }, + "title": "List of environment variables that are allowed to be present in the attestation" + }, + "runner": { + "$ref": "#/definitions/CraftingSchemaRunner", + "title": "Runner configuration for the contract" + }, + "policies": { + "$ref": "#/definitions/v1Policies", + "title": "Policies to apply to this contract" + }, + "policy_groups": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyGroupAttachment" + }, + "title": "Policy groups to apply to this contract" + }, + "annotations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/workflowcontractv1Annotation" + }, + "title": "List of annotations that can be used to add metadata to the attestation\nthis metadata can be used later on by the integrations engine to filter and interpolate data\nIt works in addition to the annotations defined in the materials and the runner" + } + } + }, + "v1CraftingState": { + "type": "object", + "properties": { + "input_schema": { + "$ref": "#/definitions/v1CraftingSchema" + }, + "schema_v2": { + "$ref": "#/definitions/v1CraftingSchemaV2" + }, + "attestation": { + "$ref": "#/definitions/v1Attestation" + }, + "dry_run": { + "type": "boolean" + }, + "ui_dashboard_url": { + "type": "string", + "title": "External URL of the platform UI, if available" + } + }, + "title": "Intermediate information that will get stored in the system while the run is being executed" + }, + "v1CursorPaginationRequest": { + "type": "object", + "properties": { + "cursor": { + "type": "string" + }, + "limit": { + "type": "integer", + "format": "int32", + "title": "Limit pagination to 100" + } + } + }, + "v1CursorPaginationResponse": { + "type": "object", + "properties": { + "next_cursor": { + "type": "string" + } + } + }, + "v1DailyRunsCountResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/DailyRunsCountResponseTotalByDay" + } + } + } + }, + "v1DeleteMembershipResponse": { + "type": "object" + }, + "v1FindOrCreateWorkflowResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowItem" + } + } + }, + "v1GenerateSigningCertResponse": { + "type": "object", + "properties": { + "chain": { + "$ref": "#/definitions/v1CertificateChain" + } + } + }, + "v1GetDownloadURLResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1GetDownloadURLResponseResult" + } + } + }, + "v1GetDownloadURLResponseResult": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "v1GetTrustedRootResponse": { + "type": "object", + "properties": { + "keys": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1CertificateChain" + }, + "title": "map keyID (cert SubjectKeyIdentifier) to PEM encoded chains" + }, + "timestamp_authorities": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/v1CertificateChain" + }, + "title": "timestamp authorities" + } + } + }, + "v1Group": { + "type": "object", + "properties": { + "id": { + "type": "string", + "title": "Unique identifier for the group" + }, + "name": { + "type": "string", + "title": "Human-readable name of the group" + }, + "description": { + "type": "string", + "title": "Additional details about the group's purpose" + }, + "organization_id": { + "type": "string", + "title": "UUID of the organization that this group belongs to" + }, + "member_count": { + "type": "integer", + "format": "int32", + "title": "Count of members in the group" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the group was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the group was last modified" + } + }, + "title": "Group represents a collection of users with shared access to resources" + }, + "v1GroupMember": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/v1User", + "title": "The user who is a member of the group" + }, + "is_maintainer": { + "type": "boolean", + "title": "Indicates whether the user has maintainer (admin) privileges in the group" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the group membership was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the group membership was last modified" + } + }, + "title": "GroupMember represents a user's membership within a group with their role information" + }, + "v1GroupServiceAddMemberResponse": { + "type": "object", + "title": "GroupServiceAddMemberResponse contains the information about the group member that was added" + }, + "v1GroupServiceCreateResponse": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/v1Group", + "title": "The created group with all its attributes" + } + }, + "title": "GroupServiceCreateResponse contains the newly created group" + }, + "v1GroupServiceDeleteResponse": { + "type": "object", + "title": "GroupServiceDeleteResponse is returned upon successful deletion of a group" + }, + "v1GroupServiceGetResponse": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/v1Group", + "title": "The requested group with all its attributes" + } + }, + "title": "GroupServiceGetResponse contains the requested group information" + }, + "v1GroupServiceListMembersResponse": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1GroupMember" + }, + "title": "List of members in the group" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + } + }, + "v1GroupServiceListPendingInvitationsResponse": { + "type": "object", + "properties": { + "invitations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PendingGroupInvitation" + }, + "title": "List of pending invitations for the group" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + }, + "title": "GroupServiceListPendingInvitationsResponse contains a list of pending invitations for a group" + }, + "v1GroupServiceListProjectsResponse": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/GroupServiceListProjectsResponseProjectInfo" + }, + "title": "List of projects memberships matching the request criteria" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + }, + "title": "GroupServiceListProjectsResponse contains a paginated list of projects for a group" + }, + "v1GroupServiceListResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1Group" + }, + "title": "List of groups matching the request criteria" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + }, + "title": "GroupServiceListsResponse contains a paginated list of groups" + }, + "v1GroupServiceRemoveMemberResponse": { + "type": "object", + "title": "GroupServiceRemoveMemberResponse is returned upon successful removal of a user from a group" + }, + "v1GroupServiceUpdateMemberMaintainerStatusResponse": { + "type": "object", + "title": "GroupServiceUpdateMemberMaintainerStatusResponse is returned upon successful update of a member's maintainer status" + }, + "v1GroupServiceUpdateResponse": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/v1Group", + "title": "The updated group with all its attributes" + } + }, + "title": "GroupServiceUpdateResponse contains the updated group information" + }, + "v1IdentityReference": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "ID is optional, but if provided, it must be a valid UUID." + }, + "name": { + "type": "string", + "description": "Name is optional, but if provided, it must be a non-empty string." + } + }, + "description": "IdentityReference represents a reference to an identity in the system." + }, + "v1InfozResponse": { + "type": "object", + "properties": { + "login_url": { + "type": "string" + }, + "version": { + "type": "string" + }, + "chart_version": { + "type": "string", + "title": "Version of the helm chart used during deployment" + }, + "restricted_org_creation": { + "type": "boolean", + "title": "Whether organization creation is restricted to admins" + }, + "ui_dashboard_url": { + "type": "string", + "title": "Link to the platform UI, if available" + } + } + }, + "v1IntegrationAttachmentItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "config": { + "type": "string", + "format": "byte", + "title": "Arbitrary configuration for the attachment" + }, + "integration": { + "$ref": "#/definitions/v1RegisteredIntegrationItem" + }, + "workflow": { + "$ref": "#/definitions/v1WorkflowItem" + } + } + }, + "v1IntegrationAvailableItem": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Integration identifier" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fanout": { + "$ref": "#/definitions/v1PluginFanout" + } + } + }, + "v1IntegrationsServiceAttachResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1IntegrationAttachmentItem" + } + } + }, + "v1IntegrationsServiceDeregisterResponse": { + "type": "object" + }, + "v1IntegrationsServiceDescribeRegistrationResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1RegisteredIntegrationItem" + } + } + }, + "v1IntegrationsServiceDetachResponse": { + "type": "object" + }, + "v1IntegrationsServiceListAvailableResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1IntegrationAvailableItem" + } + } + } + }, + "v1IntegrationsServiceListRegistrationsResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1RegisteredIntegrationItem" + } + } + } + }, + "v1IntegrationsServiceRegisterResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1RegisteredIntegrationItem" + } + } + }, + "v1JSONFilter": { + "type": "object", + "properties": { + "field_path": { + "type": "string" + }, + "operator": { + "$ref": "#/definitions/v1JSONOperator" + }, + "value": { + "type": "string" + } + }, + "description": "JSONFilter represents a filter for JSON fields." + }, + "v1JSONOperator": { + "type": "string", + "enum": [ + "JSON_OPERATOR_UNSPECIFIED", + "JSON_OPERATOR_EQ", + "JSON_OPERATOR_NEQ", + "JSON_OPERATOR_IN" + ], + "default": "JSON_OPERATOR_UNSPECIFIED", + "description": "JSONOperator represents supported JSON filter operators." + }, + "v1ListAttachmentsResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1IntegrationAttachmentItem" + } + } + } + }, + "v1MembershipRole": { + "type": "string", + "enum": [ + "MEMBERSHIP_ROLE_UNSPECIFIED", + "MEMBERSHIP_ROLE_ORG_VIEWER", + "MEMBERSHIP_ROLE_ORG_ADMIN", + "MEMBERSHIP_ROLE_ORG_OWNER", + "MEMBERSHIP_ROLE_ORG_MEMBER", + "MEMBERSHIP_ROLE_ORG_CONTRIBUTOR" + ], + "default": "MEMBERSHIP_ROLE_UNSPECIFIED" + }, + "v1Metadata": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "the name of the policy" + }, + "description": { + "type": "string" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "organization": { + "type": "string" + }, + "finding_type": { + "type": "string", + "title": "Declares the structured output schema for policy violations.\nWhen set, the policy engine validates that violations conform to the\ncorresponding proto message:\n VULNERABILITY -\u003e attestation.v1.PolicyVulnerabilityFinding\n SAST -\u003e attestation.v1.PolicySASTFinding\n LICENSE_VIOLATION -\u003e attestation.v1.PolicyLicenseViolationFinding" + } + } + }, + "v1MetricsRunnerCount": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "format": "int32" + }, + "runner_type": { + "$ref": "#/definitions/RunnerRunnerType" + } + } + }, + "v1MetricsStatusCount": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "format": "int32" + }, + "status": { + "$ref": "#/definitions/v1RunStatus" + } + } + }, + "v1MetricsTimeWindow": { + "type": "string", + "enum": [ + "METRICS_TIME_WINDOW_UNSPECIFIED", + "METRICS_TIME_WINDOW_LAST_DAY", + "METRICS_TIME_WINDOW_LAST_7_DAYS", + "METRICS_TIME_WINDOW_LAST_30_DAYS", + "METRICS_TIME_WINDOW_LAST_90_DAYS" + ], + "default": "METRICS_TIME_WINDOW_UNSPECIFIED" + }, + "v1OffsetPaginationRequest": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "description": "The (zero-based) offset of the first item returned in the collection." + }, + "page_size": { + "type": "integer", + "format": "int32", + "description": "The maximum number of entries to return. If the value exceeds the maximum, then the maximum value will be used." + } + }, + "title": "OffsetPaginationRequest is used to paginate the results" + }, + "v1OffsetPaginationResponse": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32", + "title": "The current page number" + }, + "page_size": { + "type": "integer", + "format": "int32", + "title": "The number of results per page" + }, + "total_count": { + "type": "integer", + "format": "int32", + "title": "The total number of results" + }, + "total_pages": { + "type": "integer", + "format": "int32", + "title": "The total number of pages" + } + }, + "title": "OffsetPaginationResponse is used to return the pagination information" + }, + "v1OrgInvitationItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "receiver_email": { + "type": "string" + }, + "sender": { + "$ref": "#/definitions/v1User" + }, + "organization": { + "$ref": "#/definitions/v1OrgItem" + }, + "status": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/v1MembershipRole" + } + } + }, + "v1OrgInvitationServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgInvitationItem" + } + } + }, + "v1OrgInvitationServiceListSentResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1OrgInvitationItem" + } + } + } + }, + "v1OrgInvitationServiceRevokeResponse": { + "type": "object" + }, + "v1OrgItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "default_policy_violation_strategy": { + "$ref": "#/definitions/OrgItemPolicyViolationBlockingStrategy" + }, + "policy_allowed_hostnames": { + "type": "array", + "items": { + "type": "string" + } + }, + "prevent_implicit_workflow_creation": { + "type": "boolean", + "title": "prevent workflows and projects from being created implicitly during attestation init" + }, + "restrict_contract_creation_to_org_admins": { + "type": "boolean", + "title": "restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles)" + }, + "api_token_max_days_inactive": { + "type": "integer", + "format": "int32", + "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled." + }, + "enable_ai_agent_collector": { + "type": "boolean", + "title": "Whether AI agent config collection is automatically enabled during attestation init" + }, + "block_attestations_on_released_versions": { + "type": "boolean", + "title": "Whether new attestations are rejected on project versions that are already released (prerelease == false)" + }, + "skip_runner_env_vars": { + "type": "boolean", + "title": "Whether the environment variables automatically discovered by the CI runner are skipped from the attestation" + } + } + }, + "v1OrgMembershipItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "org": { + "$ref": "#/definitions/v1OrgItem" + }, + "user": { + "$ref": "#/definitions/v1User" + }, + "current": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "role": { + "$ref": "#/definitions/v1MembershipRole" + } + } + }, + "v1OrgMetricsServiceTotalsResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgMetricsServiceTotalsResponseResult" + } + } + }, + "v1OrgMetricsServiceTotalsResponseResult": { + "type": "object", + "properties": { + "runs_total": { + "type": "integer", + "format": "int32" + }, + "runs_total_by_status": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1MetricsStatusCount" + } + }, + "runs_total_by_runner_type": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1MetricsRunnerCount" + } + } + } + }, + "v1OrganizationServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgItem" + } + } + }, + "v1OrganizationServiceDeleteMembershipResponse": { + "type": "object" + }, + "v1OrganizationServiceDeleteResponse": { + "type": "object" + }, + "v1OrganizationServiceListMembershipsResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1OrgMembershipItem" + } + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + } + }, + "v1OrganizationServiceUpdateMembershipResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgMembershipItem" + } + } + }, + "v1OrganizationServiceUpdateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgItem" + } + } + }, + "v1PendingGroupInvitation": { + "type": "object", + "properties": { + "user_email": { + "type": "string", + "title": "The email address of the user invited to the group" + }, + "invited_by": { + "$ref": "#/definitions/v1User", + "title": "The user who sent the invitation" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the invitation was created" + }, + "invitation_id": { + "type": "string", + "title": "Unique identifier for the invitation" + } + }, + "title": "PendingInvitation represents an invitation to join a group that has not yet been accepted" + }, + "v1PendingProjectInvitation": { + "type": "object", + "properties": { + "user_email": { + "type": "string", + "title": "The email address of the user invited to the project" + }, + "invited_by": { + "$ref": "#/definitions/v1User", + "title": "The user who sent the invitation" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the invitation was created" + }, + "invitation_id": { + "type": "string", + "title": "Unique identifier for the invitation" + } + }, + "title": "PendingInvitation represents an invitation to join a project that has not yet been accepted" + }, + "v1PluginFanout": { + "type": "object", + "properties": { + "registration_schema": { + "type": "string", + "format": "byte", + "title": "Registration JSON schema" + }, + "attachment_schema": { + "type": "string", + "format": "byte", + "title": "Attachment JSON schema" + }, + "subscribed_materials": { + "type": "array", + "items": { + "type": "string" + }, + "title": "List of materials that the integration is subscribed to" + } + }, + "title": "PluginFanout describes a plugin that can be used to fanout attestation and materials to multiple integrations" + }, + "v1Policies": { + "type": "object", + "properties": { + "materials": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyAttachment" + }, + "title": "Policies to be applied to materials" + }, + "attestation": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyAttachment" + }, + "title": "Policies to be applied to attestation metadata" + } + } + }, + "v1Policy": { + "type": "object", + "properties": { + "api_version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/v1Metadata" + }, + "spec": { + "$ref": "#/definitions/v1PolicySpec" + } + }, + "title": "Represents a policy to be applied to a material or attestation" + }, + "v1PolicyAssessment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Platform assessment UUID." + }, + "status": { + "type": "string", + "description": "Platform AssessmentStatus enum name, e.g. \"ASSESSMENT_STATUS_NOT_AFFECTED\"." + }, + "scope": { + "type": "string", + "description": "Platform AssessmentScope enum name, e.g. \"ASSESSMENT_SCOPE_PROJECT\"." + } + }, + "description": "Audit-critical subset of an assessment. Full record is reachable via\nAssessmentService.Get on the platform using `id`." + }, + "v1PolicyAssessmentResult": { + "type": "object", + "properties": { + "effective_status": { + "type": "string", + "description": "Precedence-resolved status across `assessments`. Values use the\nplatform's AssessmentStatus enum names, e.g.\n\"ASSESSMENT_STATUS_NOT_AFFECTED\"." + }, + "assessments": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyAssessment" + }, + "description": "Assessments matching the finding's identity tuple." + } + }, + "description": "Assessment context attached to a policy finding by the policy engine via\nthe chainloop.effective_assessments builtin. Sent to CAS as part of the\nPolicyEvaluationBundle." + }, + "v1PolicyAttachment": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "policy reference, it might be in URI format." + }, + "embedded": { + "$ref": "#/definitions/v1Policy", + "title": "meant to be used to embed the policy in the contract" + }, + "selector": { + "$ref": "#/definitions/PolicyAttachmentMaterialSelector", + "title": "rules to select a material or materials to be validated by the policy.\nIf none provided, the whole statement will be injected to the policy" + }, + "disabled": { + "type": "boolean", + "title": "set to true to disable this rule" + }, + "with": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "optional arguments for policies. Multivalued arguments can be set through multiline strings or comma separated values. It will be\nparsed and passed as an array value to the policy engine.\nwith:\n user: john\n users: john, sarah\n licenses: |\n AGPL-1.0\n AGPL-3.0" + }, + "requirements": { + "type": "array", + "items": { + "type": "string" + }, + "title": "List of requirements this policy contributes to satisfy" + }, + "gate": { + "type": "boolean", + "title": "Controls whether policy violations act as a gate.\n- true: policy violations are blocking for this policy\n- false: policy violations are non-blocking for this policy\n- unset: inherit organization-level default behavior" + } + }, + "title": "A policy to be applied to this contract" + }, + "v1PolicyEvaluationViolation": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "message": { + "type": "string" + }, + "vulnerability": { + "$ref": "#/definitions/v1PolicyVulnerabilityFinding" + }, + "sast": { + "$ref": "#/definitions/v1PolicySASTFinding" + }, + "license_violation": { + "$ref": "#/definitions/v1PolicyLicenseViolationFinding" + }, + "suppress": { + "type": "boolean", + "description": "Suppression hint set by the policy. When true the gate count\nexcludes this entry, but it is still stored in CAS and ingested\n(audit trail preserved). Set by the policy author (typically based\non `assessment.effective_status`) — the engine reads the bool\nwithout interpreting why." + } + } + }, + "v1PolicyEvaluations": { + "type": "object", + "properties": { + "evaluations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/controlplanev1PolicyEvaluation" + } + } + } + }, + "v1PolicyEvaluationsRef": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "title": "Digest of the bundle as stored in CAS, in \"sha256:\u003chex\u003e\" form" + }, + "size_bytes": { + "type": "string", + "format": "int64", + "description": "Size of the bundle in bytes. Zero when the size could not be determined." + }, + "media_type": { + "type": "string", + "title": "Media type of the bundle, i.e application/vnd.chainloop.policy-evaluations.v1+json" + }, + "reason": { + "$ref": "#/definitions/PolicyEvaluationsRefReason", + "description": "Why the evaluations were not inlined. REASON_UNSPECIFIED when they were." + }, + "inlined": { + "type": "boolean", + "description": "True when policy_evaluations carries the evaluations decoded from this\nbundle, i.e the reference is informational rather than a fallback." + } + }, + "description": "Pointer to a policy-evaluation bundle held in a CAS backend. It is the\ncanonical location of the evaluations, returned both alongside them and, when\nthey could not be inlined, in their place." + }, + "v1PolicyGatesFilter": { + "type": "string", + "enum": [ + "POLICY_GATES_FILTER_UNSPECIFIED", + "POLICY_GATES_FILTER_WITH_GATES", + "POLICY_GATES_FILTER_WITHOUT_GATES" + ], + "default": "POLICY_GATES_FILTER_UNSPECIFIED", + "description": "Server-side filter aligned with PolicyStatusSummary.has_gates." + }, + "v1PolicyGroup": { + "type": "object", + "properties": { + "api_version": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/v1Metadata" + }, + "spec": { + "$ref": "#/definitions/PolicyGroupPolicyGroupSpec" + } + }, + "title": "Represents a group or policies" + }, + "v1PolicyGroupAttachment": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "title": "Group reference, it might be an URL or a provider reference" + }, + "with": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "group arguments" + }, + "skip": { + "type": "array", + "items": { + "type": "string" + }, + "title": "policy names to skip (matched against metadata.name)" + }, + "gate": { + "type": "boolean", + "title": "Controls whether policy violations act as a gate for this group.\n- true: policy violations are blocking for this policy group\n- false: policy violations are non-blocking for this policy group\n- unset: inherit organization-level default behavior" + } + }, + "title": "Represents a group attachment in a contract" + }, + "v1PolicyGroupMaterial": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/MaterialMaterialType" + }, + "name": { + "type": "string", + "title": "Free form name, as we support placeholders eg `{{ inputs.input_name }}`\nIf no name is provided, material won't be enforced and will apply policies if `type` matches" + }, + "optional": { + "type": "boolean" + }, + "match_mode": { + "$ref": "#/definitions/MaterialSelectorMatchMode", + "description": "How `name` is matched against a material's name. Defaults to exact match\n(UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\na name prefix, mirroring PolicyAttachment.MaterialSelector." + }, + "policies": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyAttachment" + }, + "title": "Policies to be applied to this material" + } + }, + "title": "Policy group materials" + }, + "v1PolicyInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "type": "string" + } + } + }, + "v1PolicyLicenseViolationFinding": { + "type": "object", + "properties": { + "message": { + "type": "string", + "title": "Human-readable violation description" + }, + "component_name": { + "type": "string", + "title": "Component name (e.g., \"lodash\")" + }, + "package_purl": { + "type": "string", + "title": "Package URL (e.g., pkg:npm/lodash@4.17.21)" + }, + "license_id": { + "type": "string", + "title": "License identifier (e.g., \"GPL-3.0\", \"AGPL-3.0\")" + }, + "license_name": { + "type": "string", + "title": "Human-readable license name" + }, + "component_version": { + "type": "string", + "title": "Component version" + }, + "recommendation": { + "type": "string", + "title": "Suggested fix" + }, + "assessment": { + "$ref": "#/definitions/v1PolicyAssessmentResult", + "description": "Optional assessment context. See PolicyAssessmentResult." + } + }, + "description": "Output schema for license violation findings from policy evaluation.\nUsed when a policy declares finding_type: LICENSE_VIOLATION." + }, + "v1PolicyReference": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "digest": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "organization": { + "type": "string" + }, + "uri": { + "type": "string" + } + } + }, + "v1PolicySASTFinding": { + "type": "object", + "properties": { + "message": { + "type": "string", + "title": "Human-readable violation description" + }, + "rule_id": { + "type": "string", + "title": "Tool-specific rule identifier (e.g., java:S1234, go-sec:G101)" + }, + "severity": { + "type": "string", + "title": "Severity level (CRITICAL, HIGH, MEDIUM, LOW)" + }, + "location": { + "type": "string", + "title": "File path where the issue was found" + }, + "line_number": { + "type": "integer", + "format": "int32", + "title": "Line number in the file" + }, + "code_snippet": { + "type": "string", + "title": "Code snippet showing the issue" + }, + "recommendation": { + "type": "string", + "title": "Suggested fix" + }, + "severity_score": { + "type": "number", + "format": "double", + "title": "Optional numeric severity score from the scanner (scale is tool-defined)" + }, + "assessment": { + "$ref": "#/definitions/v1PolicyAssessmentResult", + "description": "Optional assessment context. See PolicyAssessmentResult." + } + }, + "description": "Output schema for SAST findings from policy evaluation.\nUsed when a policy declares finding_type: SAST." + }, + "v1PolicySpec": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "path to a policy script. It might consist of a URI reference" + }, + "embedded": { + "type": "string", + "title": "embedded source code (only Rego supported currently)" + }, + "type": { + "$ref": "#/definitions/MaterialMaterialType", + "description": "if set, it will match any material supported by Chainloop\nexcept those not having a direct schema (STRING, ARTIFACT, EVIDENCE), since their format cannot be guessed by the crafter.\nCONTAINER, HELM_CHART are also excluded, but we might implement custom policies for them in the future." + }, + "policies": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicySpecV2" + } + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PolicyInput" + }, + "title": "Describe the supported inputs" + }, + "auto_match": { + "$ref": "#/definitions/v1AutoMatch" + } + } + }, + "v1PolicySpecV2": { + "type": "object", + "properties": { + "path": { + "type": "string", + "title": "path to a policy script. It might consist of a URI reference\ndeprecated: use ref instead" + }, + "embedded": { + "type": "string", + "title": "embedded source code (only Rego supported currently)" + }, + "ref": { + "type": "string", + "title": "generic reference for file:// and http(s):// schemes" + }, + "kind": { + "$ref": "#/definitions/MaterialMaterialType", + "title": "if set, it will match any material supported by Chainloop" + }, + "attestation_phases": { + "type": "array", + "items": { + "$ref": "#/definitions/v1AttestationPhase" + }, + "description": "Controls at which attestation phases this policy is evaluated.\nEmpty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\nOnly applicable when kind is ATTESTATION." + } + } + }, + "v1PolicyStatus": { + "type": "string", + "enum": [ + "POLICY_STATUS_UNSPECIFIED", + "POLICY_STATUS_NOT_APPLICABLE", + "POLICY_STATUS_PASSED", + "POLICY_STATUS_SKIPPED", + "POLICY_STATUS_WARNING", + "POLICY_STATUS_BLOCKED", + "POLICY_STATUS_BYPASSED" + ], + "default": "POLICY_STATUS_UNSPECIFIED", + "description": "Canonical, server-computed categorical policy outcome for an attestation.\nCollapses the raw enforcement/bypass/violation signals on\nAttestationItem.PolicyEvaluationStatus into a single flat value so that\nlist and describe surfaces can render a consistent badge without\nre-deriving.\n\n - POLICY_STATUS_NOT_APPLICABLE: No policies were evaluated on this run\n - POLICY_STATUS_PASSED: Policies ran with no violations and no skips\n - POLICY_STATUS_SKIPPED: No violations but at least one evaluation was skipped\n - POLICY_STATUS_WARNING: Has violations but enforcement is advisory — run succeeded\n - POLICY_STATUS_BLOCKED: Has gated violations or enforced strategy with violations; not bypassed\n - POLICY_STATUS_BYPASSED: Enforcement would have blocked the run but was bypassed" + }, + "v1PolicyStatusFilter": { + "type": "string", + "enum": [ + "POLICY_STATUS_FILTER_UNSPECIFIED", + "POLICY_STATUS_FILTER_NOT_APPLICABLE", + "POLICY_STATUS_FILTER_PASSED", + "POLICY_STATUS_FILTER_SKIPPED", + "POLICY_STATUS_FILTER_WARNING", + "POLICY_STATUS_FILTER_BLOCKED", + "POLICY_STATUS_FILTER_BYPASSED" + ], + "default": "POLICY_STATUS_FILTER_UNSPECIFIED", + "description": "Server-side filter aligned 1:1 with PolicyStatus values." + }, + "v1PolicyStatusSummary": { + "type": "object", + "properties": { + "status": { + "$ref": "#/definitions/v1PolicyStatus" + }, + "total": { + "type": "integer", + "format": "int32", + "title": "Total number of policy evaluations that ran for this attestation" + }, + "passed": { + "type": "integer", + "format": "int32", + "title": "Number of evaluations with no violations and not skipped" + }, + "skipped": { + "type": "integer", + "format": "int32", + "title": "Number of evaluations that were skipped" + }, + "violated": { + "type": "integer", + "format": "int32", + "title": "Total number of violations across all evaluations" + }, + "has_gates": { + "type": "boolean", + "description": "Whether this run had gates in effect — any policy marked gate:true or\nthe contract using the ENFORCED blocking strategy. Independent of status:\na PASSED run can still have has_gates=true." + }, + "suppressed": { + "type": "integer", + "format": "int32", + "description": "Number of suppressed violations across all evaluations. Excluded\nfrom violated; kept in the CAS audit trail." + } + }, + "description": "PolicyStatusSummary bundles the canonical PolicyStatus with per-evaluation\ncounters. It is surfaced on both WorkflowRunItem (list response) and\nAttestationItem.PolicyEvaluationStatus (describe response) and is computed\nby a single backend helper so list and describe cannot drift." + }, + "v1PolicyViolation": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "message": { + "type": "string" + }, + "suppress": { + "type": "boolean", + "description": "Whether this violation was excluded from the policy gate (still kept\nin the CAS audit trail)." + }, + "vulnerability": { + "$ref": "#/definitions/v1PolicyVulnerabilityFinding" + }, + "sast": { + "$ref": "#/definitions/v1PolicySASTFinding" + }, + "license_violation": { + "$ref": "#/definitions/v1PolicyLicenseViolationFinding" + } + } + }, + "v1PolicyViolationsFilter": { + "type": "string", + "enum": [ + "POLICY_VIOLATIONS_FILTER_UNSPECIFIED", + "POLICY_VIOLATIONS_FILTER_WITH_VIOLATIONS", + "POLICY_VIOLATIONS_FILTER_WITHOUT_VIOLATIONS" + ], + "default": "POLICY_VIOLATIONS_FILTER_UNSPECIFIED", + "description": "Deprecated: use PolicyStatusFilter which aligns 1:1 with PolicyStatus and\nlets callers distinguish warning/blocked/bypassed from the coarse with/\nwithout-violations split." + }, + "v1PolicyVulnerabilityFinding": { + "type": "object", + "properties": { + "message": { + "type": "string", + "title": "Human-readable violation description" + }, + "external_id": { + "type": "string", + "title": "Vulnerability identifier (e.g., CVE-2024-1234, GHSA-xxxx)" + }, + "package_purl": { + "type": "string", + "title": "Package URL of the affected component (e.g., pkg:golang/example.com/lib@v1.0.0)" + }, + "severity": { + "type": "string", + "title": "Severity level (CRITICAL, HIGH, MEDIUM, LOW)" + }, + "cvss_v3_score": { + "type": "number", + "format": "double", + "title": "CVSS v3 score (0.0-10.0)" + }, + "cwes": { + "type": "array", + "items": { + "type": "string" + }, + "title": "CWE references (e.g., CWE-79, CWE-89)" + }, + "recommendation": { + "type": "string", + "title": "Suggested fix or upgrade path" + }, + "description": { + "type": "string", + "title": "Optional longer description of the vulnerability" + }, + "fixed_version": { + "type": "string", + "title": "Version that fixes the vulnerability (e.g., \"2.0.1\", \"1.3.4-patch1\")" + }, + "assessment": { + "$ref": "#/definitions/v1PolicyAssessmentResult", + "description": "Optional assessment context. See PolicyAssessmentResult." + } + }, + "description": "Output schema for vulnerability findings from policy evaluation.\nUsed when a policy declares finding_type: VULNERABILITY." + }, + "v1ProjectMember": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/v1User", + "title": "The user who is a member of the project" + }, + "group": { + "$ref": "#/definitions/v1Group", + "title": "The group who is a member of the project" + }, + "role": { + "$ref": "#/definitions/v1ProjectMemberRole", + "title": "The role of the user in the project" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the project membership was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Timestamp when the project membership was last modified" + }, + "latest_project_version_id": { + "type": "string", + "title": "The ID of latest project version this member is associated with" + }, + "parent_id": { + "type": "string", + "title": "Optional parent resource ID for nested project memberships" + } + }, + "title": "ProjectMember represents an user or group who is a member of a project" + }, + "v1ProjectMemberRole": { + "type": "string", + "enum": [ + "PROJECT_MEMBER_ROLE_UNSPECIFIED", + "PROJECT_MEMBER_ROLE_ADMIN", + "PROJECT_MEMBER_ROLE_VIEWER" + ], + "default": "PROJECT_MEMBER_ROLE_UNSPECIFIED", + "description": "- PROJECT_MEMBER_ROLE_UNSPECIFIED: Default role for a project member\n - PROJECT_MEMBER_ROLE_ADMIN: Admin role for a project member\n - PROJECT_MEMBER_ROLE_VIEWER: Viewer role for a project member", + "title": "ProjectMemberRole defines the roles a member can have in a project" + }, + "v1ProjectMembershipReference": { + "type": "object", + "properties": { + "user_email": { + "type": "string", + "title": "The user to add to the project" + }, + "group_reference": { + "$ref": "#/definitions/v1IdentityReference", + "title": "The group to add to the project" + } + }, + "title": "ProjectMembershipReference is used to reference a user or group in the context of project membership" + }, + "v1ProjectServiceAddMemberResponse": { + "type": "object", + "title": "ProjectServiceAddMemberResponse contains the result of adding a user to a project" + }, + "v1ProjectServiceListMembersResponse": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ProjectMember" + }, + "title": "The list of members in the project" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + }, + "title": "ProjectServiceListMembersResponse contains the list of members in a project" + }, + "v1ProjectServiceListPendingInvitationsResponse": { + "type": "object", + "properties": { + "invitations": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1PendingProjectInvitation" + }, + "title": "List of pending invitations for the project" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + } + }, + "title": "ProjectServiceListPendingInvitationsResponse contains a list of pending invitations for a project" + }, + "v1ProjectServiceListResponse": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/ProjectServiceListResponseProjectItem" + }, + "title": "List of projects matching the request criteria" + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse", + "title": "Pagination information for the response" + }, + "can_create_project": { + "type": "boolean", + "description": "Whether the caller may create a project in this organization. It is a\nproperty of their organization role, not of any project, so it does not\nbelong on the items: an organization contributor, for instance, can be an\nadministrator of several projects and still create none. Clients offering\nto create one need this to avoid an option that always fails." + } + }, + "title": "ProjectServiceListResponse contains a paginated list of the organization's projects" + }, + "v1ProjectServiceRemoveMemberResponse": { + "type": "object", + "title": "ProjectServiceRemoveMemberResponse is returned upon successful removal of a user from a project" + }, + "v1ProjectServiceUpdateMemberRoleResponse": { + "type": "object", + "title": "ProjectServiceUpdateMemberRoleResponse is returned upon successful update of a member's role in a project" + }, + "v1ReferrerEdge": { + "type": "object", + "properties": { + "from": { + "type": "integer", + "format": "int64", + "title": "Index of the lower end of the connection, always smaller than to" + }, + "to": { + "type": "integer", + "format": "int64", + "title": "Index of the higher end of the connection" + } + }, + "description": "A connection between two referrers, as indexes into the request", + "title": "ReferrerEdge" + }, + "v1ReferrerItem": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "title": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead" + }, + "kind": { + "type": "string", + "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ..." + }, + "downloadable": { + "type": "boolean", + "title": "Downloadable indicates whether the referrer is downloadable or not from CAS" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ReferrerItem" + }, + "title": "References contains the list of related referrer items" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "CreatedAt is the timestamp when the referrer was created" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Metadata contains additional descriptive information about the referrer" + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Annotations are key-value pairs associated with the referrer" + } + }, + "description": "It represents a referrer object in the system", + "title": "ReferrerItem" + }, + "v1ReferrerRef": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "title": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead" + }, + "kind": { + "type": "string", + "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ..." + } + }, + "description": "A reference to a referrer by digest and kind", + "title": "ReferrerRef" + }, + "v1ReferrerServiceDiscoverEdgesRequest": { + "type": "object", + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ReferrerRef" + }, + "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\nthe kind travels with it.\n\nThis is a set. A referrer may be listed more than once — a client that collected referrers\nfrom several attestations will have repeats — and is then reported at the position it first\nappeared in. At least two distinct referrers are required." + }, + "project_name": { + "type": "string", + "title": "Restricts the answer to a project, the same way DiscoverPrivate does" + }, + "project_version": { + "type": "string", + "title": "Only meaningful alongside project_name: version names are unique within a project" + } + }, + "description": "Request to discover the edges between a set of referrers", + "title": "ReferrerServiceDiscoverEdgesRequest" + }, + "v1ReferrerServiceDiscoverEdgesResponse": { + "type": "object", + "properties": { + "edges": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1ReferrerEdge" + }, + "title": "Each connection once, regardless of the direction it is stored in, and referred to by the\nfirst position its referrers were listed under in the request" + } + }, + "description": "Response for the DiscoverEdges method", + "title": "ReferrerServiceDiscoverEdgesResponse" + }, + "v1ReferrerServiceDiscoverPrivateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1ReferrerItem", + "title": "Result is the discovered referrer item" + }, + "pagination": { + "$ref": "#/definitions/v1CursorPaginationResponse", + "title": "Pagination information for the references list" + } + }, + "description": "Response for the DiscoverPrivate method", + "title": "ReferrerServiceDiscoverPrivateResponse" + }, + "v1RegisteredIntegrationItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "title": "unique, DNS-like name for the registration" + }, + "kind": { + "type": "string" + }, + "description": { + "type": "string", + "title": "Description of the registration, used for display purposes" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "config": { + "type": "string", + "format": "byte", + "title": "Arbitrary configuration for the integration" + } + } + }, + "v1RemotePolicyReference": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "digest": { + "type": "string" + } + } + }, + "v1RunStatus": { + "type": "string", + "enum": [ + "RUN_STATUS_UNSPECIFIED", + "RUN_STATUS_INITIALIZED", + "RUN_STATUS_SUCCEEDED", + "RUN_STATUS_FAILED", + "RUN_STATUS_EXPIRED", + "RUN_STATUS_CANCELLED" + ], + "default": "RUN_STATUS_UNSPECIFIED" + }, + "v1RunnerEnvironment": { + "type": "object", + "properties": { + "workflow_file_path": { + "type": "string", + "title": "Workflow file path that was used during build" + }, + "environment": { + "type": "string", + "title": "Runner environment name, i.e. github-hosted" + }, + "authenticated": { + "type": "boolean", + "title": "Whether the runner is authenticated, i.e. via the OIDC token" + }, + "type": { + "$ref": "#/definitions/RunnerRunnerType", + "title": "Runner type" + }, + "url": { + "type": "string", + "title": "Runner URL" + } + }, + "title": "The runner environment in which the attestation was crafted" + }, + "v1ScopedEntity": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Type is the type of the scoped entity i.e project or org" + }, + "id": { + "type": "string", + "title": "ID is the id of the scoped entity" + }, + "name": { + "type": "string", + "title": "Name is the name of the scoped entity" + } + } + }, + "v1SetCurrentMembershipResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1OrgMembershipItem" + } + } + }, + "v1StatuszResponse": { + "type": "object" + }, + "v1TopWorkflowsByRunsCountResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/TopWorkflowsByRunsCountResponseTotalByStatus" + } + } + } + }, + "v1User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "instance_admin": { + "type": "boolean" + } + } + }, + "v1UserServiceListMembershipsResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1OrgMembershipItem" + } + } + } + }, + "v1WorkflowActivityWindow": { + "type": "string", + "enum": [ + "WORKFLOW_ACTIVITY_WINDOW_UNSPECIFIED", + "WORKFLOW_ACTIVITY_WINDOW_LAST_DAY", + "WORKFLOW_ACTIVITY_WINDOW_LAST_7_DAYS", + "WORKFLOW_ACTIVITY_WINDOW_LAST_30_DAYS" + ], + "default": "WORKFLOW_ACTIVITY_WINDOW_UNSPECIFIED", + "description": "WorkflowActivityWindow represents the time window for the last known workflow activity." + }, + "v1WorkflowContractItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "title": "deprecated: description now belongs to the version, as it's versioned since the introduction of new contracts v2" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "latest_revision": { + "type": "integer", + "format": "int32" + }, + "latest_revision_created_at": { + "type": "string", + "format": "date-time" + }, + "workflow_names": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Workflows associated with this contract" + }, + "workflow_refs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1WorkflowRef" + } + }, + "scoped_entity": { + "$ref": "#/definitions/v1ScopedEntity", + "title": "wether the contract is scoped to an entity in the organization" + } + } + }, + "v1WorkflowContractServiceApplyResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowContractItem" + }, + "unchanged": { + "type": "boolean", + "title": "Deprecated: use changed instead" + }, + "changed": { + "type": "boolean", + "title": "Whether the resource was changed" + }, + "status": { + "$ref": "#/definitions/WorkflowContractServiceApplyResponseApplyStatus", + "title": "Detailed outcome of the apply operation" + }, + "current_revision": { + "type": "integer", + "format": "int32", + "description": "Current revision of the contract after the apply.\nFor a newly created contract this is the first revision; for an\nunchanged contract it stays at the existing revision; for dry runs it\nreflects the existing revision (0 when the contract does not exist yet)." + } + } + }, + "v1WorkflowContractServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowContractItem" + } + } + }, + "v1WorkflowContractServiceDeleteResponse": { + "type": "object" + }, + "v1WorkflowContractServiceDescribeResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowContractServiceDescribeResponseResult" + } + } + }, + "v1WorkflowContractServiceDescribeResponseResult": { + "type": "object", + "properties": { + "contract": { + "$ref": "#/definitions/v1WorkflowContractItem" + }, + "revision": { + "$ref": "#/definitions/v1WorkflowContractVersionItem" + } + } + }, + "v1WorkflowContractServiceListResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1WorkflowContractItem" + } + } + } + }, + "v1WorkflowContractServiceUpdateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowContractServiceUpdateResponseResult" + } + } + }, + "v1WorkflowContractServiceUpdateResponseResult": { + "type": "object", + "properties": { + "contract": { + "$ref": "#/definitions/v1WorkflowContractItem" + }, + "revision": { + "$ref": "#/definitions/v1WorkflowContractVersionItem" + } + } + }, + "v1WorkflowContractVersionItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision": { + "type": "integer", + "format": "int32" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "v1": { + "$ref": "#/definitions/v1CraftingSchema", + "title": "Deprecated in favor of raw_contract" + }, + "raw_contract": { + "$ref": "#/definitions/WorkflowContractVersionItemRawBody" + }, + "contract_name": { + "type": "string", + "title": "The name of the contract" + }, + "description": { + "type": "string", + "title": "The contract version description" + } + } + }, + "v1WorkflowItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project": { + "type": "string" + }, + "project_id": { + "type": "string" + }, + "team": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "runs_count": { + "type": "integer", + "format": "int32" + }, + "last_run": { + "$ref": "#/definitions/v1WorkflowRunItem" + }, + "contract_name": { + "type": "string" + }, + "contract_revision_latest": { + "type": "integer", + "format": "int32", + "title": "Current, latest revision of the contract" + }, + "public": { + "type": "boolean", + "description": "Deprecated: public workflows were removed. This field is always false and\nis retained only for wire compatibility; it will be removed in a future release." + }, + "description": { + "type": "string" + }, + "workflow_template_id": { + "type": "string", + "description": "Optional reference to the platform workflow template this workflow was created from.\nEmpty when the workflow is not bound to any template." + } + } + }, + "v1WorkflowMetadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "project": { + "type": "string" + }, + "project_version": { + "type": "string", + "title": "kept for backwards compatibility with remote state storage" + }, + "version": { + "$ref": "#/definitions/attestationv1ProjectVersion", + "title": "project version" + }, + "team": { + "type": "string" + }, + "workflow_id": { + "type": "string" + }, + "workflow_run_id": { + "type": "string", + "title": "Not required since we might be doing a dry-run" + }, + "schema_revision": { + "type": "string" + }, + "contract_name": { + "type": "string", + "title": "contract name (contract version is \"schema_revision\")" + }, + "organization": { + "type": "string", + "title": "organization name" + } + } + }, + "v1WorkflowRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_name": { + "type": "string" + } + } + }, + "v1WorkflowRunItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "state": { + "type": "string", + "title": "TODO: use runStatus enum below\ndeprecated field, use status instead" + }, + "status": { + "$ref": "#/definitions/v1RunStatus" + }, + "reason": { + "type": "string" + }, + "workflow": { + "$ref": "#/definitions/v1WorkflowItem" + }, + "job_url": { + "type": "string" + }, + "runner_type": { + "$ref": "#/definitions/RunnerRunnerType", + "title": "string runner_type = 8;" + }, + "contract_version": { + "$ref": "#/definitions/v1WorkflowContractVersionItem" + }, + "contract_revision_used": { + "type": "integer", + "format": "int32", + "title": "The revision of the contract used for this run" + }, + "contract_revision_latest": { + "type": "integer", + "format": "int32", + "title": "The latest revision available for this contract at the time of the run" + }, + "version": { + "$ref": "#/definitions/controlplanev1ProjectVersion", + "title": "The version of the project the attestation was initiated with" + }, + "has_policy_violations": { + "type": "boolean", + "description": "Whether the run has policy violations (null if no policies were evaluated)\nDeprecated: use policy_summary.violated \u003e 0, or the richer\npolicy_summary.status, which also distinguishes skipped / warning /\nblocked / bypassed outcomes." + }, + "policy_summary": { + "$ref": "#/definitions/v1PolicyStatusSummary", + "description": "Canonical policy status summary for this run (null if no policies were\nevaluated). Carries both the categorical PolicyStatus and per-evaluation\ncounters so list consumers can render a badge without calling View." + } + } + }, + "v1WorkflowRunServiceListResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1WorkflowRunItem" + } + }, + "pagination": { + "$ref": "#/definitions/v1CursorPaginationResponse" + } + } + }, + "v1WorkflowRunServiceViewResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowRunServiceViewResponseResult" + } + } + }, + "v1WorkflowRunServiceViewResponseResult": { + "type": "object", + "properties": { + "org_name": { + "type": "string" + }, + "workflow_run": { + "$ref": "#/definitions/v1WorkflowRunItem" + }, + "attestation": { + "$ref": "#/definitions/v1AttestationItem" + }, + "verification": { + "$ref": "#/definitions/WorkflowRunServiceViewResponseVerificationResult", + "title": "It will be nil if the verification is not possible (old or non-keyless attestations)" + } + } + }, + "v1WorkflowServiceCreateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowItem" + } + } + }, + "v1WorkflowServiceDeleteResponse": { + "type": "object" + }, + "v1WorkflowServiceListResponse": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1WorkflowItem" + } + }, + "pagination": { + "$ref": "#/definitions/v1OffsetPaginationResponse" + } + } + }, + "v1WorkflowServiceUpdateResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowItem" + } + } + }, + "v1WorkflowServiceViewResponse": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/v1WorkflowItem" + } + } + }, + "workflowcontractv1Annotation": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Single word optionally separated with _" + }, + "value": { + "type": "string", + "title": "This value can be set in the contract or provided during the attestation" + } + } + } + }, + "securityDefinitions": { + "bearerToken": { + "type": "apiKey", + "description": "Bearer token for authentication", + "name": "Authorization", + "in": "header" + } + }, + "security": [ + { + "bearerToken": [] + } + ], + "externalDocs": { + "description": "Chainloop Official Documentation", + "url": "https://docs.chainloop.dev" + } +} diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 5b2c4c0ac..48746be27 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -847,12 +847,11 @@ type PolicyAttachment struct { // optional arguments for policies. Multivalued arguments can be set through multiline strings or comma separated values. It will be // parsed and passed as an array value to the policy engine. // with: - // - // user: john - // users: john, sarah - // licenses: | - // AGPL-1.0 - // AGPL-3.0 + // user: john + // users: john, sarah + // licenses: | + // AGPL-1.0 + // AGPL-3.0 With map[string]string `protobuf:"bytes,5,rep,name=with,proto3" json:"with,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // List of requirements this policy contributes to satisfy Requirements []string `protobuf:"bytes,6,rep,name=requirements,proto3" json:"requirements,omitempty"` @@ -1052,10 +1051,9 @@ type Metadata struct { // Declares the structured output schema for policy violations. // When set, the policy engine validates that violations conform to the // corresponding proto message: - // - // VULNERABILITY -> attestation.v1.PolicyVulnerabilityFinding - // SAST -> attestation.v1.PolicySASTFinding - // LICENSE_VIOLATION -> attestation.v1.PolicyLicenseViolationFinding + // VULNERABILITY -> attestation.v1.PolicyVulnerabilityFinding + // SAST -> attestation.v1.PolicySASTFinding + // LICENSE_VIOLATION -> attestation.v1.PolicyLicenseViolationFinding FindingType *string `protobuf:"bytes,7,opt,name=finding_type,json=findingType,proto3,oneof" json:"finding_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 743761d60..5f07ce67a 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -251,8 +251,15 @@ func (s *ReferrerUseCase) EdgesAmong(ctx context.Context, nodes []*ReferrerRef, ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.EdgesAmong") defer span.End() - if len(nodes) < 2 { - return nil, nil + // nodes is a set. Naming the same referrer twice is accepted — a client that collected + // referrers from more than one attestation will have repeats — but fewer than two distinct + // ones describes no connection, and is a mistake rather than a question. + distinct := make(map[string]struct{}, len(nodes)) + for _, n := range nodes { + distinct[newRef(n.Digest, n.Kind)] = struct{}{} + } + if len(distinct) < 2 { + return nil, NewErrValidationStr("at least two distinct referrers are required") } filters := make([]GetFromRootFilter, 0, len(extraFilters)+1) diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 5183705ec..a05b03a91 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -760,19 +760,25 @@ func (s *referrerIntegrationTestSuite) TestEdgesAmong() { s.Empty(edges) }) - s.Run("fewer than two referrers is answered without touching the store", func() { - edges, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation}, s.user.ID) - s.NoError(err) - s.Empty(edges) + s.Run("fewer than two referrers is refused without touching the store", func() { + _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation}, s.user.ID) + s.Error(err) + s.True(biz.IsErrValidation(err), "one referrer describes no connection") }) - s.Run("duplicated input still yields one edge per connection", func() { + // A client that collected referrers from more than one attestation will list a shared + // material twice. That is accepted, and the answer names the position it first appeared in. + s.Run("a referrer listed twice is answered at its first position", func() { nodes := []*biz.ReferrerRef{attestation, materials[0], materials[0]} edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) s.NoError(err) - // The repeated material resolves to the same referrer, which the caller listed twice; the - // answer names whichever position it was indexed under, once. - s.Len(edges, 1) + s.Equal([]biz.ReferrerEdge{{From: 0, To: 1}}, edges, "index 2 repeats index 1, which is the one named") + }) + + s.Run("the same referrer twice is not two referrers", func() { + _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, attestation}, s.user.ID) + s.Error(err, "two positions naming one referrer describe no connection") + s.True(biz.IsErrValidation(err)) }) } diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index f807082c6..cc9d7610f 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -650,9 +650,14 @@ func (r *ReferrerRepo) EdgesAmong(ctx context.Context, nodes []*biz.ReferrerRef, } // Index the answer by the position the caller gave each referrer, so only positions travel back. + // A referrer the caller listed more than once is answered at the position it first appeared + // in, so the answer does not depend on the order the map happens to be written in. indexOf := make(map[string]int, len(nodes)) for i, n := range nodes { - indexOf[newRefKey(n.Digest, n.Kind)] = i + key := newRefKey(n.Digest, n.Kind) + if _, seen := indexOf[key]; !seen { + indexOf[key] = i + } } position := make(map[uuid.UUID]int, len(visible)) attestationIDs := make([]uuid.UUID, 0, len(visible)) From 3a11e7900ca12004ffaca8e0b93592b107e0406e Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 11:50:52 +0200 Subject: [PATCH 10/15] fix(controlplane): identify a referrer by the pair, not by the two joined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscoverEdges built its lookup keys as kind + "-" + digest. Kind is whatever the caller sends, so a separator inside it makes two different referrers share a key: {A, B-C} and {A-B, C} both encode to A-B-C. That made the request look like it named one referrer where it named two, so it was refused, and in the position map one referrer's edges could be reported against the other's index. Both maps are keyed by the pair itself, which is comparable and cannot be ambiguous. No ingested kind contains a separator today, so this was not reachable through the UI or the CLI — but kind arrives from the request, not from the store, so nothing prevented it. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/biz/referrer.go | 4 ++-- .../pkg/biz/referrer_integration_test.go | 9 +++++++++ app/controlplane/pkg/data/referrer.go | 14 +++++++------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 5f07ce67a..cd56cb895 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -254,9 +254,9 @@ func (s *ReferrerUseCase) EdgesAmong(ctx context.Context, nodes []*ReferrerRef, // nodes is a set. Naming the same referrer twice is accepted — a client that collected // referrers from more than one attestation will have repeats — but fewer than two distinct // ones describes no connection, and is a mistake rather than a question. - distinct := make(map[string]struct{}, len(nodes)) + distinct := make(map[ReferrerRef]struct{}, len(nodes)) for _, n := range nodes { - distinct[newRef(n.Digest, n.Kind)] = struct{}{} + distinct[*n] = struct{}{} } if len(distinct) < 2 { return nil, NewErrValidationStr("at least two distinct referrers are required") diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index a05b03a91..30fe81717 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -775,6 +775,15 @@ func (s *referrerIntegrationTestSuite) TestEdgesAmong() { s.Equal([]biz.ReferrerEdge{{From: 0, To: 1}}, edges, "index 2 repeats index 1, which is the one named") }) + // Identity is the pair, not the two joined together: kind is whatever the caller sent, so a + // separator inside it must not make two different referrers count as one. + s.Run("referrers that differ only in where the kind ends are two referrers", func() { + nodes := []*biz.ReferrerRef{{Kind: "A", Digest: "B-C"}, {Kind: "A-B", Digest: "C"}} + edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.NoError(err, "these are two distinct referrers and must not be refused as one") + s.Empty(edges, "neither exists, so neither contributes an edge") + }) + s.Run("the same referrer twice is not two referrers", func() { _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, attestation}, s.user.ID) s.Error(err, "two positions naming one referrer describe no connection") diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index cc9d7610f..14232ba8b 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -652,18 +652,20 @@ func (r *ReferrerRepo) EdgesAmong(ctx context.Context, nodes []*biz.ReferrerRef, // Index the answer by the position the caller gave each referrer, so only positions travel back. // A referrer the caller listed more than once is answered at the position it first appeared // in, so the answer does not depend on the order the map happens to be written in. - indexOf := make(map[string]int, len(nodes)) + // Keyed by the pair itself rather than by the two joined into a string: kind is whatever the + // caller sent, so any separator could appear inside it and make two different referrers look + // like one. + indexOf := make(map[biz.ReferrerRef]int, len(nodes)) for i, n := range nodes { - key := newRefKey(n.Digest, n.Kind) - if _, seen := indexOf[key]; !seen { - indexOf[key] = i + if _, seen := indexOf[*n]; !seen { + indexOf[*n] = i } } position := make(map[uuid.UUID]int, len(visible)) attestationIDs := make([]uuid.UUID, 0, len(visible)) visibleIDs := make([]uuid.UUID, 0, len(visible)) for _, ref := range visible { - i, ok := indexOf[newRefKey(ref.Digest, ref.Kind)] + i, ok := indexOf[biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}] if !ok { continue } @@ -766,5 +768,3 @@ func edgesAmongQuery(fromIDs, toIDs []uuid.UUID) (string, []any) { )). Query() } - -func newRefKey(digest, kind string) string { return kind + "-" + digest } From 476b74f116fe99c3ed47289e403d84460ec43003 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 12:04:22 +0200 Subject: [PATCH 11/15] build(controlplane): check in the API generation this branch actually produces Running the full generation target rather than buf on its own showed three things wrong with what was checked in. The OpenAPI spec was stale: the request message now documents that its nodes are a set, and that description belongs in the published spec too. A temporary artifact of the generation, api/gen/temp-openapi, had been added to the repository. The target writes it, merges it into the spec and removes it again, so checking it in leaves the tree dirty every time anyone regenerates. crafting_schema.pb.go carried a difference produced by a local protoc-gen-go rather than by any change here, and is restored to what the repository already had. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/api/gen/openapi/openapi.yaml | 15 +- .../api/gen/temp-openapi/apidocs.swagger.json | 4315 ----------------- .../workflowcontract/v1/crafting_schema.pb.go | 18 +- 3 files changed, 24 insertions(+), 4324 deletions(-) delete mode 100644 app/controlplane/api/gen/temp-openapi/apidocs.swagger.json diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index da9b3b42f..6942a1b12 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -354,6 +354,15 @@ components: identify a referrer, so the kind travels with it. + + + This is a set. A referrer may be listed more than once — a client + that collected referrers + + from several attestations will have repeats — and is then reported + at the position it first + + appeared in. At least two distinct referrers are required. items: $ref: '#/components/schemas/v1ReferrerRef' type: array @@ -379,7 +388,11 @@ components: edges: items: $ref: '#/components/schemas/v1ReferrerEdge' - title: Each connection once, regardless of the direction it is stored in + title: >- + Each connection once, regardless of the direction it is stored in, + and referred to by the + + first position its referrers were listed under in the request type: array title: ReferrerServiceDiscoverEdgesResponse type: object diff --git a/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json b/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json deleted file mode 100644 index d769d8a9b..000000000 --- a/app/controlplane/api/gen/temp-openapi/apidocs.swagger.json +++ /dev/null @@ -1,4315 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "Chainloop Controlplane API", - "termsOfService": "https://chainloop.dev/terms", - "version": "1.0", - "contact": { - "name": "Chainloop Support", - "url": "https://chainloop.dev", - "email": "support@chainloop.dev" - } - }, - "tags": [ - { - "name": "APITokenService" - }, - { - "name": "AttestationStateService" - }, - { - "name": "AuthService" - }, - { - "name": "CASBackendService" - }, - { - "name": "CASCredentialsService" - }, - { - "name": "CASRedirectService" - }, - { - "name": "ContextService" - }, - { - "name": "GroupService" - }, - { - "name": "IntegrationsService" - }, - { - "name": "OrgInvitationService" - }, - { - "name": "OrgMetricsService" - }, - { - "name": "OrganizationService" - }, - { - "name": "ProjectService" - }, - { - "name": "ReferrerService", - "description": "Referrer service for discovering referred content by digest" - }, - { - "name": "SigningService" - }, - { - "name": "StatusService" - }, - { - "name": "UserService" - }, - { - "name": "WorkflowService" - }, - { - "name": "WorkflowContractService" - }, - { - "name": "AttestationService" - }, - { - "name": "WorkflowRunService" - } - ], - "host": "cp.chainloop.dev", - "schemes": [ - "https" - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": { - "/discover/edges": { - "post": { - "summary": "Discover the edges between known referrers", - "description": "Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges.", - "operationId": "ReferrerService_DiscoverEdges", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ReferrerServiceDiscoverEdgesResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/googlerpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "description": "Request to discover the edges between a set of referrers", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1ReferrerServiceDiscoverEdgesRequest" - } - } - ], - "tags": [ - "ReferrerService" - ], - "produces": [ - "application/json" - ] - } - }, - "/discover/{digest}": { - "get": { - "summary": "Discover private referrer", - "description": "Returns the referrer item for a given digest in the organizations of the logged-in user", - "operationId": "ReferrerService_DiscoverPrivate", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ReferrerServiceDiscoverPrivateResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/googlerpcStatus" - } - } - }, - "parameters": [ - { - "name": "digest", - "description": "Digest is the unique identifier of the referrer to discover", - "in": "path", - "required": true, - "type": "string" - }, - { - "name": "kind", - "description": "Kind is the optional type of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ...\nUsed to filter and resolve ambiguities", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "pagination.cursor", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "pagination.limit", - "description": "Limit pagination to 100", - "in": "query", - "required": false, - "type": "integer", - "format": "int32" - }, - { - "name": "project_name", - "description": "ProjectName optionally scopes the discovery to a project by name. Can be set on its own\n(project-wide filter) or together with project_version (project + version filter).", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "project_version", - "description": "ProjectVersion optionally scopes the discovery to a project version (by name, e.g. v1.2.0).\nRequires project_name, since a version name is unique only within a project.", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "ReferrerService" - ], - "produces": [ - "application/json" - ] - } - }, - "/infoz": { - "get": { - "operationId": "StatusService_Infoz", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1InfozResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/googlerpcStatus" - } - } - }, - "tags": [ - "StatusService" - ] - } - }, - "/statusz": { - "get": { - "operationId": "StatusService_Statusz", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1StatuszResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/googlerpcStatus" - } - } - }, - "parameters": [ - { - "name": "readiness", - "description": "Parameter that can be used by readiness probes\nThe main difference is that readiness probes will take into account that all\ndependent services are up and ready", - "in": "query", - "required": false, - "type": "boolean" - } - ], - "tags": [ - "StatusService" - ] - } - } - }, - "definitions": { - "APITokenServiceCreateResponseAPITokenFull": { - "type": "object", - "properties": { - "item": { - "$ref": "#/definitions/v1APITokenItem" - }, - "jwt": { - "type": "string" - } - } - }, - "APITokenServiceListRequestScope": { - "type": "string", - "enum": [ - "SCOPE_UNSPECIFIED", - "SCOPE_PROJECT", - "SCOPE_GLOBAL" - ], - "default": "SCOPE_UNSPECIFIED" - }, - "APITokenServiceListRequestStatusFilter": { - "type": "string", - "enum": [ - "STATUS_FILTER_ACTIVE", - "STATUS_FILTER_REVOKED", - "STATUS_FILTER_ALL" - ], - "default": "STATUS_FILTER_ACTIVE", - "description": "buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX\nSTATUS_FILTER_ACTIVE = 0 is intentional: active tokens are the default,\nmatching proto3 zero-value semantics and preserving backward compatibility.\n\n - STATUS_FILTER_ACTIVE: Only active (non-revoked) tokens. This is the default.\n - STATUS_FILTER_REVOKED: Only revoked tokens.\n - STATUS_FILTER_ALL: All tokens regardless of revocation status." - }, - "AttestationAuth": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/AuthAuthType" - }, - "id": { - "type": "string", - "title": "Identifier of the authentication (user ID, token ID, etc.)" - } - } - }, - "AttestationCASBackend": { - "type": "object", - "properties": { - "cas_backend_id": { - "type": "string", - "title": "UUID of the CAS backend" - }, - "cas_backend_name": { - "type": "string", - "title": "Name of the CAS backend" - }, - "fallback": { - "type": "boolean", - "title": "Whether this is a fallback backend" - } - } - }, - "AttestationItemEnvVariable": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "AttestationItemPolicyEvaluationStatus": { - "type": "object", - "properties": { - "strategy": { - "type": "string" - }, - "bypassed": { - "type": "boolean" - }, - "blocked": { - "type": "boolean" - }, - "has_violations": { - "type": "boolean" - }, - "has_gated_violations": { - "type": "boolean" - }, - "evaluations_count": { - "type": "integer", - "format": "int32", - "description": "Deprecated: use summary.total instead." - }, - "violations_count": { - "type": "integer", - "format": "int32", - "description": "Deprecated: use summary.violated instead." - }, - "summary": { - "$ref": "#/definitions/v1PolicyStatusSummary", - "description": "Canonical categorical status + counters. Single source of truth for UI\nconsumers — consumers that re-derive from the raw bools above tend to\ndisagree on semantics, especially around gating and bypass." - } - } - }, - "AttestationServiceCancelRequestTriggerType": { - "type": "string", - "enum": [ - "TRIGGER_TYPE_UNSPECIFIED", - "TRIGGER_TYPE_FAILURE", - "TRIGGER_TYPE_CANCELLATION" - ], - "default": "TRIGGER_TYPE_UNSPECIFIED" - }, - "AuthAuthType": { - "type": "string", - "enum": [ - "AUTH_TYPE_UNSPECIFIED", - "AUTH_TYPE_USER", - "AUTH_TYPE_API_TOKEN", - "AUTH_TYPE_FEDERATED" - ], - "default": "AUTH_TYPE_UNSPECIFIED" - }, - "CASBackendItemLimits": { - "type": "object", - "properties": { - "max_bytes": { - "type": "string", - "format": "int64", - "title": "Max number of bytes allowed to be stored in this backend" - } - } - }, - "CASBackendItemValidationStatus": { - "type": "string", - "enum": [ - "VALIDATION_STATUS_UNSPECIFIED", - "VALIDATION_STATUS_OK", - "VALIDATION_STATUS_INVALID" - ], - "default": "VALIDATION_STATUS_UNSPECIFIED" - }, - "CASCredentialsServiceGetRequestRole": { - "type": "string", - "enum": [ - "ROLE_UNSPECIFIED", - "ROLE_DOWNLOADER", - "ROLE_UPLOADER" - ], - "default": "ROLE_UNSPECIFIED" - }, - "CommitCommitVerification": { - "type": "object", - "properties": { - "attempted": { - "type": "boolean", - "title": "Whether verification was attempted" - }, - "status": { - "$ref": "#/definitions/CommitVerificationVerificationStatus", - "title": "Verification status" - }, - "reason": { - "type": "string", - "title": "Human-readable reason for the status" - }, - "platform": { - "type": "string", - "title": "Platform that performed the verification (e.g., \"github\", \"gitlab\")" - }, - "key_id": { - "type": "string", - "title": "Optional: The signing key ID if verified" - }, - "signature_algorithm": { - "type": "string", - "title": "Optional: The signature algorithm used" - } - } - }, - "CommitRemote": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "CommitVerificationVerificationStatus": { - "type": "string", - "enum": [ - "unspecified", - "verified", - "unverified", - "unavailable", - "not_applicable" - ], - "default": "unspecified", - "description": "- verified: Successfully verified by platform\n - unverified: Platform checked but signature is invalid/unverified\n - unavailable: Verification could not be performed (no API access, network error, etc.)\n - not_applicable: Platform doesn't support verification or no commit signature present", - "title": "buf:lint:ignore ENUM_VALUE_UPPER_SNAKE_CASE\nbuf:lint:ignore ENUM_VALUE_PREFIX\nbuf:lint:ignore ENUM_ZERO_VALUE_SUFFIX" - }, - "CraftingSchemaRunner": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/RunnerRunnerType" - } - } - }, - "DailyRunsCountResponseTotalByDay": { - "type": "object", - "properties": { - "date": { - "type": "string", - "title": "string format: \"YYYY-MM-DD\"" - }, - "metrics": { - "$ref": "#/definitions/v1MetricsStatusCount" - } - } - }, - "GroupServiceListProjectsResponseProjectInfo": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "Unique identifier of the project" - }, - "name": { - "type": "string", - "title": "Name of the project" - }, - "description": { - "type": "string", - "title": "Description of the project" - }, - "role": { - "$ref": "#/definitions/v1ProjectMemberRole", - "title": "Role of the group in the project (admin or viewer)" - }, - "latest_version_id": { - "type": "string", - "title": "The latest version ID of the project, if available" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the membership was created" - } - }, - "title": "ProjectInfo represents detailed information about a project that a group is a member of" - }, - "MaterialArtifact": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" - }, - "name": { - "type": "string", - "title": "filename, use for record purposes" - }, - "digest": { - "type": "string", - "title": "the digest is enough to retrieve the artifact since it's stored in a CAS\nwhich also has annotated the fileName" - }, - "is_subject": { - "type": "boolean" - }, - "content": { - "type": "string", - "format": "byte", - "title": "Inline content of the artifact.\nThis is optional and is used for small artifacts that can be stored inline in the attestation" - } - } - }, - "MaterialContainerImage": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" - }, - "name": { - "type": "string" - }, - "digest": { - "type": "string" - }, - "is_subject": { - "type": "boolean" - }, - "tag": { - "type": "string", - "title": "provided tag" - }, - "signature_digest": { - "type": "string", - "title": "Digest of the found signature for the image" - }, - "signature_provider": { - "type": "string", - "title": "The provider in charge of the signature" - }, - "signature": { - "type": "string", - "title": "Base64 encoded signature payload, aka the OCI Signature Manifest" - }, - "has_latest_tag": { - "type": "boolean", - "description": "Indicates if the image has the latest tag. The image being checked\nmight not explicitly have the latest tag, but it could also be tagged\nwith the latest tag." - } - } - }, - "MaterialKeyVal": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "NOT USED, kept for compatibility with servers that still perform server-side validation``\nTODO: remove after some time" - }, - "value": { - "type": "string" - }, - "digest": { - "type": "string" - } - } - }, - "MaterialMaterialType": { - "type": "string", - "enum": [ - "MATERIAL_TYPE_UNSPECIFIED", - "STRING", - "CONTAINER_IMAGE", - "ARTIFACT", - "SBOM_CYCLONEDX_JSON", - "SBOM_SPDX_JSON", - "JUNIT_XML", - "OPENVEX", - "HELM_CHART", - "SARIF", - "EVIDENCE", - "ATTESTATION", - "CSAF_VEX", - "CSAF_INFORMATIONAL_ADVISORY", - "CSAF_SECURITY_ADVISORY", - "CSAF_SECURITY_INCIDENT_RESPONSE", - "GITLAB_SECURITY_REPORT", - "ZAP_DAST_ZIP", - "BLACKDUCK_SCA_JSON", - "TWISTCLI_SCAN_JSON", - "GHAS_CODE_SCAN", - "GHAS_SECRET_SCAN", - "GHAS_DEPENDENCY_SCAN", - "JACOCO_XML", - "SLSA_PROVENANCE", - "CHAINLOOP_RUNNER_CONTEXT", - "CHAINLOOP_PR_INFO", - "GITLEAKS_JSON", - "CHAINLOOP_AI_AGENT_CONFIG", - "CHAINLOOP_AI_CODING_SESSION", - "OPENAPI_SPEC", - "ASYNCAPI_SPEC", - "GRAPHQL_SPEC", - "YELP_DETECT_SECRETS_BASELINE", - "SYSINTERNALS_SIGCHECK", - "SYSINTERNALS_ACCESSCHK", - "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON", - "RADAMSA_REPORT", - "RADAMSA_CRASHES", - "TRUFFLEHOG_JSON", - "COBERTURA_XML", - "CHECKMARX_JSON", - "OVERSECURED_JSON", - "PITEST_XML", - "CHAINLOOP_AI_SECURITY_CONTEXT" - ], - "default": "MATERIAL_TYPE_UNSPECIFIED", - "title": "- OPENVEX: https://github.com/openvex/spec\n - HELM_CHART: Static analysis output format\nhttps://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md\n - EVIDENCE: Pieces of evidences represent generic, additional context that don't fit\ninto one of the well known material types. For example, a custom approval report (in json), ...\n - ATTESTATION: Chainloop attestation coming from a different workflow.\n - CSAF_VEX: https://docs.oasis-open.org/csaf/csaf/v2.0/cs03/csaf-v2.0-cs03.html\n - GITLAB_SECURITY_REPORT: Gitlab Application Security Reports https://docs.gitlab.com/ee/user/application_security/\n - TWISTCLI_SCAN_JSON: Twistcli scan output in json format: https://docs.prismacloud.io/en/compute-edition/30/admin-guide/tools/twistcli-scan-images\n - GHAS_CODE_SCAN: GitHub Advanced Security API reports\nhttps://docs.github.com/en/rest/code-scanning/code-scanning?apiVersion=2022-11-28\n - GHAS_SECRET_SCAN: https://docs.github.com/en/rest/secret-scanning/secret-scanning?apiVersion=2022-11-28\n - GHAS_DEPENDENCY_SCAN: https://docs.github.com/en/rest/dependabot/alerts?apiVersion=2022-11-28\n - JACOCO_XML: Jacoco coverage reports https://www.jacoco.org/userdoc/importexport.html\n - SLSA_PROVENANCE: SLSA Provenance in the form of Sigstore Bundle\nhttps://slsa.dev/spec/v1.0/provenance\nhttps://docs.sigstore.dev/about/bundle/\n - CHAINLOOP_RUNNER_CONTEXT: The Chainloop CLI plugin for runner context\n - CHAINLOOP_PR_INFO: Pull Request / Merge Request metadata collected automatically during attestation\n - GITLEAKS_JSON: Gitleaks json report https://github.com/gitleaks/gitleaks/blob/master/README.md#reporting\n - CHAINLOOP_AI_AGENT_CONFIG: AI agent configuration collected automatically during attestation\n - CHAINLOOP_AI_CODING_SESSION: AI coding session telemetry collected during attestation\n - OPENAPI_SPEC: OpenAPI specification (v3.0, v3.1)\nhttps://spec.openapis.org/oas/latest.html\n - ASYNCAPI_SPEC: AsyncAPI specification (v2.6, v3.0)\nhttps://www.asyncapi.com/docs/reference/specification/latest\n - GRAPHQL_SPEC: GraphQL SDL schema\nhttps://spec.graphql.org/\n - YELP_DETECT_SECRETS_BASELINE: detect-secrets baseline file https://github.com/Yelp/detect-secrets\n - SYSINTERNALS_SIGCHECK: Sysinternals sigcheck output in CSV format\nhttps://learn.microsoft.com/en-us/sysinternals/downloads/sigcheck\n - SYSINTERNALS_ACCESSCHK: Sysinternals AccessChk text output https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk\n - CERTCC_DRANZER: CERT/CC dranzer ActiveX/COM control test report (plain text): a single\nreport or an archive (zip or tar.gz) holding the per-mode reports of one\nrun (-b, -p, -s, -t) https://github.com/CERTCC/dranzer\n - OSSF_SCORECARD_JSON: OpenSSF Scorecard result in JSON format\nhttps://github.com/ossf/scorecard\n - RADAMSA_REPORT: radamsa -M metadata log, one record per generated iteration\nhttps://gitlab.com/akihe/radamsa\n - RADAMSA_CRASHES: radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip)\n - TRUFFLEHOG_JSON: TruffleHog secret scanning report in JSONL format (one JSON finding per line)\nhttps://github.com/trufflesecurity/trufflehog\n - COBERTURA_XML: Cobertura code coverage report in XML format\nhttps://github.com/cobertura/cobertura\n - CHECKMARX_JSON: Checkmarx One native JSON report (ScanResultsCollection)\nhttps://github.com/Checkmarx/ast-cli/blob/main/internal/wrappers/results-json.go\n - OVERSECURED_JSON: Oversecured mobile (Android/iOS) scan report, whole-scan JSON export\nhttps://docs.oversecured.com/docs/guide-exporting-reports\n - PITEST_XML: PIT mutation testing report in its native XML format (mutations.xml)\nhttps://pitest.org/\n - CHAINLOOP_AI_SECURITY_CONTEXT: Chainloop AI security context: vulnerability fingerprints mined from a\nrepository's fix history, the attack surfaces they share, ranked risks,\nand byte-verifiable evidence anchors backing each claim" - }, - "MaterialSBOMArtifact": { - "type": "object", - "properties": { - "artifact": { - "$ref": "#/definitions/MaterialArtifact", - "title": "The actual SBOM artifact" - }, - "main_component": { - "$ref": "#/definitions/SBOMArtifactMainComponent", - "title": "The Main component if any the SBOM is related to" - } - } - }, - "MaterialSelectorMatchMode": { - "type": "string", - "enum": [ - "UNSPECIFIED", - "EXACT", - "PREFIX" - ], - "default": "UNSPECIFIED", - "title": "Values are intentionally unprefixed for contract-author usability\n(\"match_mode: PREFIX\" reads better than \"MATCH_MODE_PREFIX\"), matching the\nother unprefixed enums in this file (RunnerType, MaterialType).\nbuf:lint:ignore ENUM_ZERO_VALUE_SUFFIX" - }, - "OrgItemPolicyViolationBlockingStrategy": { - "type": "string", - "enum": [ - "POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED", - "POLICY_VIOLATION_BLOCKING_STRATEGY_BLOCK", - "POLICY_VIOLATION_BLOCKING_STRATEGY_ADVISORY" - ], - "default": "POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED" - }, - "PolicyAttachmentMaterialSelector": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "material name" - }, - "match_mode": { - "$ref": "#/definitions/MaterialSelectorMatchMode", - "description": "How `name` is matched against a material's name. Defaults to exact match\n(UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\nPREFIX to target a set of materials sharing a name prefix (e.g. an\narchive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." - } - } - }, - "PolicyEvaluationRawResult": { - "type": "object", - "properties": { - "input": { - "type": "string", - "format": "byte", - "title": "Input data provided to the policy engine" - }, - "output": { - "type": "string", - "format": "byte", - "title": "Output data returned by the policy engine" - } - } - }, - "PolicyEvaluationReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "digest": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "org_name": { - "type": "string" - } - } - }, - "PolicyEvaluationsRefReason": { - "type": "string", - "enum": [ - "REASON_UNSPECIFIED", - "REASON_TOO_LARGE", - "REASON_UNAVAILABLE" - ], - "default": "REASON_UNSPECIFIED", - "title": "- REASON_TOO_LARGE: The bundle exceeds the maximum size the server inlines in a response\n - REASON_UNAVAILABLE: The bundle could not be resolved from CAS" - }, - "PolicyGroupPolicyGroupPolicies": { - "type": "object", - "properties": { - "materials": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyGroupMaterial" - } - }, - "attestation": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyAttachment" - } - } - } - }, - "PolicyGroupPolicyGroupSpec": { - "type": "object", - "properties": { - "policies": { - "$ref": "#/definitions/PolicyGroupPolicyGroupPolicies" - }, - "inputs": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyInput" - } - } - } - }, - "ProjectServiceListResponseProjectItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "Unique identifier of the project" - }, - "name": { - "type": "string", - "title": "Name of the project" - }, - "description": { - "type": "string", - "title": "Description of the project" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the project was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the project was last updated" - }, - "can_create_workflow": { - "type": "boolean", - "description": "Whether the caller may create a workflow in this project. The listing is\nfiltered by what the caller can see, which is not the same as what they\ncan write to: a project viewer can read a project but not add a workflow\nto it. This is resolved server-side because a caller can hold several\nroles on the same project, directly and through products, and any one of\nthem granting the permission is enough." - } - }, - "title": "ProjectItem represents a project of the organization" - }, - "RawBodyFormat": { - "type": "string", - "enum": [ - "FORMAT_UNSPECIFIED", - "FORMAT_JSON", - "FORMAT_YAML", - "FORMAT_CUE" - ], - "default": "FORMAT_UNSPECIFIED" - }, - "RunnerRunnerType": { - "type": "string", - "enum": [ - "RUNNER_TYPE_UNSPECIFIED", - "GITHUB_ACTION", - "GITLAB_PIPELINE", - "AZURE_PIPELINE", - "JENKINS_JOB", - "CIRCLECI_BUILD", - "DAGGER_PIPELINE", - "TEAMCITY_PIPELINE", - "TEKTON_PIPELINE", - "CHAINLOOP_SANDBOX" - ], - "default": "RUNNER_TYPE_UNSPECIFIED" - }, - "SBOMArtifactMainComponent": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "The name of the main component" - }, - "version": { - "type": "string", - "title": "The version of the main component" - }, - "kind": { - "type": "string", - "title": "The kind of the main component" - } - }, - "title": "The main component of the SBOM" - }, - "TopWorkflowsByRunsCountResponseTotalByStatus": { - "type": "object", - "properties": { - "workflow": { - "$ref": "#/definitions/v1WorkflowItem" - }, - "runs_total_by_status": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1MetricsStatusCount" - } - } - } - }, - "WorkflowContractServiceApplyResponseApplyStatus": { - "type": "string", - "enum": [ - "APPLY_STATUS_UNSPECIFIED", - "APPLY_STATUS_CREATED", - "APPLY_STATUS_UPDATED", - "APPLY_STATUS_UNCHANGED" - ], - "default": "APPLY_STATUS_UNSPECIFIED", - "description": "- APPLY_STATUS_CREATED: The resource did not exist and was created\n - APPLY_STATUS_UPDATED: The resource existed and its content changed\n - APPLY_STATUS_UNCHANGED: The resource existed and its content was identical", - "title": "ApplyStatus describes how the applied resource changed" - }, - "WorkflowContractVersionItemRawBody": { - "type": "object", - "properties": { - "body": { - "type": "string", - "format": "byte" - }, - "format": { - "$ref": "#/definitions/RawBodyFormat" - } - } - }, - "WorkflowRunServiceViewResponseVerificationResult": { - "type": "object", - "properties": { - "verified": { - "type": "boolean", - "title": "if it can be verified this will hold the result of the verification" - }, - "failure_reason": { - "type": "string", - "title": "why it couldn't be verified, or the failure reason" - } - } - }, - "attestationv1PolicyEvaluation": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "The policy name from the policy spec" - }, - "material_name": { - "type": "string" - }, - "body": { - "type": "string", - "title": "the body of the policy. This field will be empty if there is a FQDN reference to the policy" - }, - "sources": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Base64 representation of run scripts. It might be empty if there is a FQDN reference to the policy" - }, - "reference_digest": { - "type": "string", - "title": "fully qualified reference to the policy\ni.e\nhttp://my-domain.com/foo.yaml\nfile://foo.yaml\nchainloop://my-provider.com/foo@sha256:1234\nNOTE: embedded policies will not have a reference\nDeprecated: use policy_reference instead" - }, - "reference_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "violations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyEvaluationViolation" - }, - "title": "The policy violations, if any" - }, - "with": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "arguments, as they come from the policy attachment" - }, - "type": { - "$ref": "#/definitions/MaterialMaterialType", - "title": "material type, if any, of the evaluated policy" - }, - "skipped": { - "type": "boolean", - "title": "whether this evaluation was skipped or not (because of an invalid input, for example)" - }, - "skip_reasons": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Evaluation messages, intended to communicate evaluation errors (invalid input)" - }, - "policy_reference": { - "$ref": "#/definitions/PolicyEvaluationReference", - "title": "Group this evaluated policy belongs to, if any" - }, - "group_reference": { - "$ref": "#/definitions/PolicyEvaluationReference" - }, - "requirements": { - "type": "array", - "items": { - "type": "string" - }, - "title": "List of requirements this policy contributes to satisfy" - }, - "raw_results": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/PolicyEvaluationRawResult" - }, - "description": "Raw inputs and outputs from the policy engine, preserved for debugging." - }, - "gate": { - "type": "boolean", - "title": "Whether the policy evaluation result should block the attestation (inherited from the policy attachment)" - }, - "runtime_input_overrides": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Names of policy inputs whose values were supplied or overridden at runtime\n(e.g. via --policy-input-from-file) rather than coming solely from the\ncontract. The effective merged values live in `with`; this only records\nwhich input names were touched at runtime. Empty means no runtime override." - } - }, - "title": "A policy executed against an attestation or material" - }, - "attestationv1ProjectVersion": { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "prerelease": { - "type": "boolean", - "title": "if it's pre-release" - }, - "mark_as_released": { - "type": "boolean" - } - } - }, - "controlplanev1PolicyEvaluation": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "material_name": { - "type": "string" - }, - "body": { - "type": "string" - }, - "sources": { - "type": "array", - "items": { - "type": "string" - } - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "description": { - "type": "string" - }, - "with": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "type": { - "type": "string" - }, - "violations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyViolation" - } - }, - "policy_reference": { - "$ref": "#/definitions/v1PolicyReference" - }, - "skipped": { - "type": "boolean" - }, - "skip_reasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "requirements": { - "type": "array", - "items": { - "type": "string" - } - }, - "group_reference": { - "$ref": "#/definitions/v1PolicyReference" - }, - "gate": { - "type": "boolean" - } - } - }, - "controlplanev1ProjectVersion": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "version": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "released_at": { - "type": "string", - "format": "date-time", - "title": "when it was marked as released" - }, - "latest": { - "type": "boolean" - } - } - }, - "googlerpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - }, - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "protobufNullValue": { - "type": "string", - "enum": [ - "NULL_VALUE" - ], - "default": "NULL_VALUE", - "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\nThe JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." - }, - "v1APITokenItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "organization_id": { - "type": "string" - }, - "organization_name": { - "type": "string" - }, - "scoped_entity": { - "$ref": "#/definitions/v1ScopedEntity", - "title": "wether the token is scoped to an entity in the organization" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "revoked_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": "string", - "format": "date-time" - }, - "last_used_at": { - "type": "string", - "format": "date-time" - } - } - }, - "v1APITokenServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/APITokenServiceCreateResponseAPITokenFull" - } - } - }, - "v1APITokenServiceListResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1APITokenItem" - } - } - } - }, - "v1APITokenServiceRevokeResponse": { - "type": "object" - }, - "v1Attestation": { - "type": "object", - "properties": { - "initialized_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "workflow": { - "$ref": "#/definitions/v1WorkflowMetadata" - }, - "materials": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/v1AttestationMaterial" - } - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Annotations for the attestation" - }, - "env_vars": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "List of env variables" - }, - "runner_url": { - "type": "string" - }, - "runner_type": { - "$ref": "#/definitions/RunnerRunnerType" - }, - "head": { - "$ref": "#/definitions/v1Commit", - "title": "Head Commit of the environment where the attestation was executed (optional)" - }, - "policy_evaluations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/attestationv1PolicyEvaluation" - }, - "title": "Policies that materials in this attestation were validated against" - }, - "block_on_policy_violation": { - "type": "boolean", - "title": "fail the attestation if policy evaluation fails" - }, - "bypass_policy_check": { - "type": "boolean", - "title": "bypass policy check" - }, - "signing_options": { - "$ref": "#/definitions/v1AttestationSigningOptions", - "title": "Signing options" - }, - "runner_environment": { - "$ref": "#/definitions/v1RunnerEnvironment", - "title": "Runner environment in which the attestation was crafted" - }, - "auth": { - "$ref": "#/definitions/AttestationAuth", - "title": "Authentication information used during attestation" - }, - "policies_allowed_hostnames": { - "type": "array", - "items": { - "type": "string" - }, - "title": "array of hostnames that are allowed to be used in the policies" - }, - "cas_backend": { - "$ref": "#/definitions/AttestationCASBackend", - "title": "CAS backend information used during attestation" - }, - "skip_runner_env_vars": { - "type": "boolean", - "description": "skip storing the environment variables automatically discovered by the CI runner.\nThe contract's env_allow_list is not affected by this flag." - } - } - }, - "v1AttestationItem": { - "type": "object", - "properties": { - "envelope": { - "type": "string", - "format": "byte", - "title": "encoded DSEE envelope" - }, - "bundle": { - "type": "string", - "format": "byte", - "title": "Attestation bundle" - }, - "digest_in_cas_backend": { - "type": "string", - "title": "sha256sum of the bundle containing the envelope, or the envelope in old attestations\nused as a key in the CAS backend" - }, - "env_vars": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/AttestationItemEnvVariable" - }, - "title": "denormalized envelope/statement content" - }, - "materials": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1AttestationItemMaterial" - } - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "policy_evaluations": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/v1PolicyEvaluations" - } - }, - "policy_evaluation_status": { - "$ref": "#/definitions/AttestationItemPolicyEvaluationStatus" - }, - "policy_evaluations_ref": { - "$ref": "#/definitions/v1PolicyEvaluationsRef", - "description": "Reference to the policy-evaluation bundle in the CAS backend. Populated\nwhenever the attestation carries a bundle, whether or not the evaluations\nwere also inlined in policy_evaluations: see its \"inlined\" field. Fetch the\nbundle by digest to inspect the full set of evaluations.\nCounters and status in policy_evaluation_status remain complete either way." - } - } - }, - "v1AttestationItemMaterial": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string", - "description": "Deprecated: use raw_value instead. This field cannot represent binary content." - }, - "filename": { - "type": "string", - "title": "filename of the artifact that was either uploaded or injected inline in \"value\"" - }, - "type": { - "type": "string", - "title": "Material type, i.e ARTIFACT" - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "tag": { - "type": "string", - "title": "in the case of a container image, the tag of the attested image" - }, - "hash": { - "type": "string" - }, - "uploaded_to_cas": { - "type": "boolean", - "title": "it's been uploaded to an actual CAS backend" - }, - "embedded_inline": { - "type": "boolean", - "title": "the content instead if inline" - }, - "raw_value": { - "type": "string", - "format": "byte", - "description": "Binary-safe material content. For inline artifacts, contains the raw bytes.\nFor string materials, contains the UTF-8 encoded value.\nFor non-inline materials (container images, uploaded artifacts), contains\nthe filename or image reference as UTF-8.\nClients should prefer this field over the deprecated string value field." - } - } - }, - "v1AttestationMaterial": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "string": { - "$ref": "#/definitions/MaterialKeyVal" - }, - "container_image": { - "$ref": "#/definitions/MaterialContainerImage" - }, - "artifact": { - "$ref": "#/definitions/MaterialArtifact" - }, - "sbom_artifact": { - "$ref": "#/definitions/MaterialSBOMArtifact" - }, - "added_at": { - "type": "string", - "format": "date-time" - }, - "material_type": { - "$ref": "#/definitions/MaterialMaterialType" - }, - "uploaded_to_cas": { - "type": "boolean", - "title": "Whether the material has been uploaded to the CAS" - }, - "inline_cas": { - "type": "boolean", - "title": "If the material content has been injected inline in the attestation\nleveraging a form of inline CAS" - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Annotations for the material" - }, - "output": { - "type": "boolean" - }, - "required": { - "type": "boolean" - } - } - }, - "v1AttestationPhase": { - "type": "string", - "enum": [ - "ATTESTATION_PHASE_UNSPECIFIED", - "INIT", - "PUSH" - ], - "default": "ATTESTATION_PHASE_UNSPECIFIED", - "description": "Lifecycle phases that control when an attestation policy is evaluated.\nOnly applicable to policies with kind ATTESTATION in PolicySpecV2." - }, - "v1AttestationServiceCancelResponse": { - "type": "object" - }, - "v1AttestationServiceGetContractResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationServiceGetContractResponseResult" - } - } - }, - "v1AttestationServiceGetContractResponseResult": { - "type": "object", - "properties": { - "workflow": { - "$ref": "#/definitions/v1WorkflowItem" - }, - "contract": { - "$ref": "#/definitions/v1WorkflowContractVersionItem" - } - } - }, - "v1AttestationServiceGetPolicyGroupResponse": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/v1PolicyGroup" - }, - "reference": { - "$ref": "#/definitions/v1RemotePolicyReference", - "title": "FQDN of the policy in the provider" - } - } - }, - "v1AttestationServiceGetPolicyResponse": { - "type": "object", - "properties": { - "policy": { - "$ref": "#/definitions/v1Policy" - }, - "reference": { - "$ref": "#/definitions/v1RemotePolicyReference", - "title": "FQDN of the policy in the provider" - } - } - }, - "v1AttestationServiceGetUploadCredsResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationServiceGetUploadCredsResponseResult" - } - } - }, - "v1AttestationServiceGetUploadCredsResponseResult": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "backend": { - "$ref": "#/definitions/v1CASBackendItem" - } - } - }, - "v1AttestationServiceInitResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationServiceInitResponseResult" - } - } - }, - "v1AttestationServiceInitResponseResult": { - "type": "object", - "properties": { - "workflow_run": { - "$ref": "#/definitions/v1WorkflowRunItem" - }, - "organization": { - "type": "string", - "title": "organization name" - }, - "block_on_policy_violation": { - "type": "boolean", - "title": "fail the attestation if there is a violation in any policy" - }, - "signing_options": { - "$ref": "#/definitions/v1AttestationServiceInitResponseSigningOptions", - "title": "Signing options" - }, - "policies_allowed_hostnames": { - "type": "array", - "items": { - "type": "string" - }, - "title": "array of hostnames that are allowed to be used in the policies" - }, - "ui_dashboard_url": { - "type": "string", - "title": "URL pointing to the web dashboard. It might be empty if not available" - }, - "enable_ai_agent_collector": { - "type": "boolean", - "title": "Whether AI agent config collection is enabled at the org level" - }, - "skip_runner_env_vars": { - "type": "boolean", - "title": "Whether to skip storing the environment variables automatically discovered by the CI runner in the attestation" - } - } - }, - "v1AttestationServiceInitResponseSigningOptions": { - "type": "object", - "properties": { - "timestamp_authority_url": { - "type": "string", - "title": "TSA service to be used for signing" - }, - "signing_ca": { - "type": "string", - "title": "If set, the attestation wil be signed with ephemeral certificates issued by this CA" - } - } - }, - "v1AttestationServiceStoreResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationServiceStoreResponseResult" - } - } - }, - "v1AttestationServiceStoreResponseResult": { - "type": "object", - "properties": { - "digest": { - "type": "string", - "title": "attestation digest" - } - } - }, - "v1AttestationSigningOptions": { - "type": "object", - "properties": { - "timestamp_authority_url": { - "type": "string", - "title": "TSA URL" - }, - "signing_ca": { - "type": "string", - "title": "Signing CA to be used for signing" - } - } - }, - "v1AttestationStateServiceInitializedResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationStateServiceInitializedResponseResult" - } - } - }, - "v1AttestationStateServiceInitializedResponseResult": { - "type": "object", - "properties": { - "initialized": { - "type": "boolean" - } - } - }, - "v1AttestationStateServiceReadResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1AttestationStateServiceReadResponseResult" - } - } - }, - "v1AttestationStateServiceReadResponseResult": { - "type": "object", - "properties": { - "attestation_state": { - "$ref": "#/definitions/v1CraftingState" - }, - "digest": { - "type": "string", - "title": "digest of the attestation state to implement Optimistic Concurrency Control" - } - } - }, - "v1AttestationStateServiceResetResponse": { - "type": "object" - }, - "v1AttestationStateServiceSaveResponse": { - "type": "object", - "properties": { - "digest": { - "type": "string", - "title": "digest of the newly saved attestation state for Optimistic Concurrency Control" - } - } - }, - "v1AuthServiceDeleteAccountResponse": { - "type": "object", - "description": "AuthServiceDeleteAccountResponse is the response for the DeleteAccount method." - }, - "v1AutoMatch": { - "type": "object", - "properties": { - "path": { - "type": "string", - "title": "path to a policy script. It might consist of a URI reference\ndeprecated: use ref instead" - }, - "embedded": { - "type": "string", - "title": "embedded source code (only Rego supported currently)" - }, - "ref": { - "type": "string", - "title": "generic reference for file:// and http(s):// schemes" - } - }, - "title": "Auto-matching policy specification" - }, - "v1CASBackendItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "location": { - "type": "string", - "title": "e.g. myregistry.io/myrepo s3 bucket and so on" - }, - "description": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "validated_at": { - "type": "string", - "format": "date-time" - }, - "validation_status": { - "$ref": "#/definitions/CASBackendItemValidationStatus" - }, - "provider": { - "type": "string", - "description": "OCI, S3, ..." - }, - "default": { - "type": "boolean", - "title": "Wether it's the default backend in the organization" - }, - "limits": { - "$ref": "#/definitions/CASBackendItemLimits", - "title": "Limits for this backend" - }, - "is_inline": { - "type": "boolean", - "title": "Is it an inline backend?\ninline means that the content is stored in the attestation itself" - }, - "validation_error": { - "type": "string", - "title": "Error message if validation failed" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "fallback": { - "type": "boolean", - "title": "Wether it's the fallback backend in the organization" - }, - "is_managed": { - "type": "boolean", - "title": "Whether this backend is provisioned and operated by Chainloop using" - } - } - }, - "v1CASBackendServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1CASBackendItem" - } - } - }, - "v1CASBackendServiceDeleteResponse": { - "type": "object" - }, - "v1CASBackendServiceListResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1CASBackendItem" - } - } - } - }, - "v1CASBackendServiceRevalidateResponse": { - "type": "object" - }, - "v1CASBackendServiceUpdateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1CASBackendItem" - } - } - }, - "v1CASCredentialsServiceGetResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1CASCredentialsServiceGetResponseResult" - } - } - }, - "v1CASCredentialsServiceGetResponseResult": { - "type": "object", - "properties": { - "token": { - "type": "string" - }, - "backend": { - "$ref": "#/definitions/v1CASBackendItem" - } - } - }, - "v1CertificateChain": { - "type": "object", - "properties": { - "certificates": { - "type": "array", - "items": { - "type": "string" - }, - "description": "The PEM-encoded certificate chain, ordered from leaf to intermediate to root as applicable." - } - } - }, - "v1Commit": { - "type": "object", - "properties": { - "hash": { - "type": "string" - }, - "author_email": { - "type": "string", - "title": "Commit authors might not include email i.e \"Flux \u003c\u003e\"" - }, - "author_name": { - "type": "string" - }, - "message": { - "type": "string" - }, - "date": { - "type": "string", - "format": "date-time" - }, - "remotes": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/CommitRemote" - } - }, - "signature": { - "type": "string" - }, - "platform_verification": { - "$ref": "#/definitions/CommitCommitVerification", - "title": "Platform verification information (GitHub/GitLab signature verification)" - } - } - }, - "v1ContextServiceCurrentResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1ContextServiceCurrentResponseResult" - } - } - }, - "v1ContextServiceCurrentResponseResult": { - "type": "object", - "properties": { - "current_user": { - "$ref": "#/definitions/v1User" - }, - "current_membership": { - "$ref": "#/definitions/v1OrgMembershipItem" - }, - "current_cas_backend": { - "$ref": "#/definitions/v1CASBackendItem" - } - } - }, - "v1CraftingSchema": { - "type": "object", - "properties": { - "schema_version": { - "type": "string", - "title": "Version of the schema, do not confuse with the revision of the content" - }, - "materials": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1CraftingSchemaMaterial" - } - }, - "env_allow_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "runner": { - "$ref": "#/definitions/CraftingSchemaRunner" - }, - "annotations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/workflowcontractv1Annotation" - }, - "title": "List of annotations that can be used to add metadata to the attestation\nthis metadata can be used later on by the integrations engine to filter and interpolate data\nIt works in addition to the annotations defined in the materials and the runner" - }, - "policies": { - "$ref": "#/definitions/v1Policies", - "title": "Policies to apply to this schema" - }, - "policy_groups": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyGroupAttachment" - }, - "title": "Policy groups to apply to this schema" - } - }, - "title": "Schema definition provided by the user to the tool\nthat defines the schema of the workflowRun" - }, - "v1CraftingSchemaMaterial": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/MaterialMaterialType" - }, - "name": { - "type": "string" - }, - "optional": { - "type": "boolean" - }, - "output": { - "type": "boolean", - "title": "If a material is set as output it will get added to the subject in the statement" - }, - "annotations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/workflowcontractv1Annotation" - }, - "title": "List of annotations that can be used to add metadata to the material\nthis metadata can be used later on by the integrations engine to filter and interpolate data" - }, - "skip_upload": { - "type": "boolean", - "title": "If true, skip uploading the material to CAS (only record metadata)\nDefaults to false (material will be uploaded)" - }, - "group": { - "type": "string", - "description": "Choke group: materials sharing the same non-empty group value form an\n\"at least one of\" set, meaning at least one member must be present in the\nattestation. Materials without a group keep their individual required/optional behavior." - } - } - }, - "v1CraftingSchemaV2": { - "type": "object", - "properties": { - "api_version": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/definitions/v1Metadata" - }, - "spec": { - "$ref": "#/definitions/v1CraftingSchemaV2Spec" - } - }, - "title": "Schema definition provided by the user to the tool\nthat defines the schema of the workflowRun" - }, - "v1CraftingSchemaV2Spec": { - "type": "object", - "properties": { - "materials": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1CraftingSchemaMaterial" - }, - "title": "Materials that are expected to be present in the attestation" - }, - "env_allow_list": { - "type": "array", - "items": { - "type": "string" - }, - "title": "List of environment variables that are allowed to be present in the attestation" - }, - "runner": { - "$ref": "#/definitions/CraftingSchemaRunner", - "title": "Runner configuration for the contract" - }, - "policies": { - "$ref": "#/definitions/v1Policies", - "title": "Policies to apply to this contract" - }, - "policy_groups": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyGroupAttachment" - }, - "title": "Policy groups to apply to this contract" - }, - "annotations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/workflowcontractv1Annotation" - }, - "title": "List of annotations that can be used to add metadata to the attestation\nthis metadata can be used later on by the integrations engine to filter and interpolate data\nIt works in addition to the annotations defined in the materials and the runner" - } - } - }, - "v1CraftingState": { - "type": "object", - "properties": { - "input_schema": { - "$ref": "#/definitions/v1CraftingSchema" - }, - "schema_v2": { - "$ref": "#/definitions/v1CraftingSchemaV2" - }, - "attestation": { - "$ref": "#/definitions/v1Attestation" - }, - "dry_run": { - "type": "boolean" - }, - "ui_dashboard_url": { - "type": "string", - "title": "External URL of the platform UI, if available" - } - }, - "title": "Intermediate information that will get stored in the system while the run is being executed" - }, - "v1CursorPaginationRequest": { - "type": "object", - "properties": { - "cursor": { - "type": "string" - }, - "limit": { - "type": "integer", - "format": "int32", - "title": "Limit pagination to 100" - } - } - }, - "v1CursorPaginationResponse": { - "type": "object", - "properties": { - "next_cursor": { - "type": "string" - } - } - }, - "v1DailyRunsCountResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/DailyRunsCountResponseTotalByDay" - } - } - } - }, - "v1DeleteMembershipResponse": { - "type": "object" - }, - "v1FindOrCreateWorkflowResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowItem" - } - } - }, - "v1GenerateSigningCertResponse": { - "type": "object", - "properties": { - "chain": { - "$ref": "#/definitions/v1CertificateChain" - } - } - }, - "v1GetDownloadURLResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1GetDownloadURLResponseResult" - } - } - }, - "v1GetDownloadURLResponseResult": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - }, - "v1GetTrustedRootResponse": { - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/v1CertificateChain" - }, - "title": "map keyID (cert SubjectKeyIdentifier) to PEM encoded chains" - }, - "timestamp_authorities": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/v1CertificateChain" - }, - "title": "timestamp authorities" - } - } - }, - "v1Group": { - "type": "object", - "properties": { - "id": { - "type": "string", - "title": "Unique identifier for the group" - }, - "name": { - "type": "string", - "title": "Human-readable name of the group" - }, - "description": { - "type": "string", - "title": "Additional details about the group's purpose" - }, - "organization_id": { - "type": "string", - "title": "UUID of the organization that this group belongs to" - }, - "member_count": { - "type": "integer", - "format": "int32", - "title": "Count of members in the group" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the group was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the group was last modified" - } - }, - "title": "Group represents a collection of users with shared access to resources" - }, - "v1GroupMember": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User", - "title": "The user who is a member of the group" - }, - "is_maintainer": { - "type": "boolean", - "title": "Indicates whether the user has maintainer (admin) privileges in the group" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the group membership was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the group membership was last modified" - } - }, - "title": "GroupMember represents a user's membership within a group with their role information" - }, - "v1GroupServiceAddMemberResponse": { - "type": "object", - "title": "GroupServiceAddMemberResponse contains the information about the group member that was added" - }, - "v1GroupServiceCreateResponse": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/v1Group", - "title": "The created group with all its attributes" - } - }, - "title": "GroupServiceCreateResponse contains the newly created group" - }, - "v1GroupServiceDeleteResponse": { - "type": "object", - "title": "GroupServiceDeleteResponse is returned upon successful deletion of a group" - }, - "v1GroupServiceGetResponse": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/v1Group", - "title": "The requested group with all its attributes" - } - }, - "title": "GroupServiceGetResponse contains the requested group information" - }, - "v1GroupServiceListMembersResponse": { - "type": "object", - "properties": { - "members": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1GroupMember" - }, - "title": "List of members in the group" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - } - }, - "v1GroupServiceListPendingInvitationsResponse": { - "type": "object", - "properties": { - "invitations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PendingGroupInvitation" - }, - "title": "List of pending invitations for the group" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - }, - "title": "GroupServiceListPendingInvitationsResponse contains a list of pending invitations for a group" - }, - "v1GroupServiceListProjectsResponse": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/GroupServiceListProjectsResponseProjectInfo" - }, - "title": "List of projects memberships matching the request criteria" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - }, - "title": "GroupServiceListProjectsResponse contains a paginated list of projects for a group" - }, - "v1GroupServiceListResponse": { - "type": "object", - "properties": { - "groups": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1Group" - }, - "title": "List of groups matching the request criteria" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - }, - "title": "GroupServiceListsResponse contains a paginated list of groups" - }, - "v1GroupServiceRemoveMemberResponse": { - "type": "object", - "title": "GroupServiceRemoveMemberResponse is returned upon successful removal of a user from a group" - }, - "v1GroupServiceUpdateMemberMaintainerStatusResponse": { - "type": "object", - "title": "GroupServiceUpdateMemberMaintainerStatusResponse is returned upon successful update of a member's maintainer status" - }, - "v1GroupServiceUpdateResponse": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/v1Group", - "title": "The updated group with all its attributes" - } - }, - "title": "GroupServiceUpdateResponse contains the updated group information" - }, - "v1IdentityReference": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID is optional, but if provided, it must be a valid UUID." - }, - "name": { - "type": "string", - "description": "Name is optional, but if provided, it must be a non-empty string." - } - }, - "description": "IdentityReference represents a reference to an identity in the system." - }, - "v1InfozResponse": { - "type": "object", - "properties": { - "login_url": { - "type": "string" - }, - "version": { - "type": "string" - }, - "chart_version": { - "type": "string", - "title": "Version of the helm chart used during deployment" - }, - "restricted_org_creation": { - "type": "boolean", - "title": "Whether organization creation is restricted to admins" - }, - "ui_dashboard_url": { - "type": "string", - "title": "Link to the platform UI, if available" - } - } - }, - "v1IntegrationAttachmentItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "config": { - "type": "string", - "format": "byte", - "title": "Arbitrary configuration for the attachment" - }, - "integration": { - "$ref": "#/definitions/v1RegisteredIntegrationItem" - }, - "workflow": { - "$ref": "#/definitions/v1WorkflowItem" - } - } - }, - "v1IntegrationAvailableItem": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Integration identifier" - }, - "version": { - "type": "string" - }, - "description": { - "type": "string" - }, - "fanout": { - "$ref": "#/definitions/v1PluginFanout" - } - } - }, - "v1IntegrationsServiceAttachResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1IntegrationAttachmentItem" - } - } - }, - "v1IntegrationsServiceDeregisterResponse": { - "type": "object" - }, - "v1IntegrationsServiceDescribeRegistrationResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1RegisteredIntegrationItem" - } - } - }, - "v1IntegrationsServiceDetachResponse": { - "type": "object" - }, - "v1IntegrationsServiceListAvailableResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1IntegrationAvailableItem" - } - } - } - }, - "v1IntegrationsServiceListRegistrationsResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1RegisteredIntegrationItem" - } - } - } - }, - "v1IntegrationsServiceRegisterResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1RegisteredIntegrationItem" - } - } - }, - "v1JSONFilter": { - "type": "object", - "properties": { - "field_path": { - "type": "string" - }, - "operator": { - "$ref": "#/definitions/v1JSONOperator" - }, - "value": { - "type": "string" - } - }, - "description": "JSONFilter represents a filter for JSON fields." - }, - "v1JSONOperator": { - "type": "string", - "enum": [ - "JSON_OPERATOR_UNSPECIFIED", - "JSON_OPERATOR_EQ", - "JSON_OPERATOR_NEQ", - "JSON_OPERATOR_IN" - ], - "default": "JSON_OPERATOR_UNSPECIFIED", - "description": "JSONOperator represents supported JSON filter operators." - }, - "v1ListAttachmentsResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1IntegrationAttachmentItem" - } - } - } - }, - "v1MembershipRole": { - "type": "string", - "enum": [ - "MEMBERSHIP_ROLE_UNSPECIFIED", - "MEMBERSHIP_ROLE_ORG_VIEWER", - "MEMBERSHIP_ROLE_ORG_ADMIN", - "MEMBERSHIP_ROLE_ORG_OWNER", - "MEMBERSHIP_ROLE_ORG_MEMBER", - "MEMBERSHIP_ROLE_ORG_CONTRIBUTOR" - ], - "default": "MEMBERSHIP_ROLE_UNSPECIFIED" - }, - "v1Metadata": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "the name of the policy" - }, - "description": { - "type": "string" - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "organization": { - "type": "string" - }, - "finding_type": { - "type": "string", - "title": "Declares the structured output schema for policy violations.\nWhen set, the policy engine validates that violations conform to the\ncorresponding proto message:\n VULNERABILITY -\u003e attestation.v1.PolicyVulnerabilityFinding\n SAST -\u003e attestation.v1.PolicySASTFinding\n LICENSE_VIOLATION -\u003e attestation.v1.PolicyLicenseViolationFinding" - } - } - }, - "v1MetricsRunnerCount": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "format": "int32" - }, - "runner_type": { - "$ref": "#/definitions/RunnerRunnerType" - } - } - }, - "v1MetricsStatusCount": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "format": "int32" - }, - "status": { - "$ref": "#/definitions/v1RunStatus" - } - } - }, - "v1MetricsTimeWindow": { - "type": "string", - "enum": [ - "METRICS_TIME_WINDOW_UNSPECIFIED", - "METRICS_TIME_WINDOW_LAST_DAY", - "METRICS_TIME_WINDOW_LAST_7_DAYS", - "METRICS_TIME_WINDOW_LAST_30_DAYS", - "METRICS_TIME_WINDOW_LAST_90_DAYS" - ], - "default": "METRICS_TIME_WINDOW_UNSPECIFIED" - }, - "v1OffsetPaginationRequest": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "format": "int32", - "description": "The (zero-based) offset of the first item returned in the collection." - }, - "page_size": { - "type": "integer", - "format": "int32", - "description": "The maximum number of entries to return. If the value exceeds the maximum, then the maximum value will be used." - } - }, - "title": "OffsetPaginationRequest is used to paginate the results" - }, - "v1OffsetPaginationResponse": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "format": "int32", - "title": "The current page number" - }, - "page_size": { - "type": "integer", - "format": "int32", - "title": "The number of results per page" - }, - "total_count": { - "type": "integer", - "format": "int32", - "title": "The total number of results" - }, - "total_pages": { - "type": "integer", - "format": "int32", - "title": "The total number of pages" - } - }, - "title": "OffsetPaginationResponse is used to return the pagination information" - }, - "v1OrgInvitationItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "receiver_email": { - "type": "string" - }, - "sender": { - "$ref": "#/definitions/v1User" - }, - "organization": { - "$ref": "#/definitions/v1OrgItem" - }, - "status": { - "type": "string" - }, - "role": { - "$ref": "#/definitions/v1MembershipRole" - } - } - }, - "v1OrgInvitationServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgInvitationItem" - } - } - }, - "v1OrgInvitationServiceListSentResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1OrgInvitationItem" - } - } - } - }, - "v1OrgInvitationServiceRevokeResponse": { - "type": "object" - }, - "v1OrgItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "default_policy_violation_strategy": { - "$ref": "#/definitions/OrgItemPolicyViolationBlockingStrategy" - }, - "policy_allowed_hostnames": { - "type": "array", - "items": { - "type": "string" - } - }, - "prevent_implicit_workflow_creation": { - "type": "boolean", - "title": "prevent workflows and projects from being created implicitly during attestation init" - }, - "restrict_contract_creation_to_org_admins": { - "type": "boolean", - "title": "restrict_contract_creation_to_org_admins restricts contract creation (org-level and project-level) to only organization admins (owner/admin roles)" - }, - "api_token_max_days_inactive": { - "type": "integer", - "format": "int32", - "description": "Maximum days of inactivity before API tokens are auto-revoked. Absent if disabled." - }, - "enable_ai_agent_collector": { - "type": "boolean", - "title": "Whether AI agent config collection is automatically enabled during attestation init" - }, - "block_attestations_on_released_versions": { - "type": "boolean", - "title": "Whether new attestations are rejected on project versions that are already released (prerelease == false)" - }, - "skip_runner_env_vars": { - "type": "boolean", - "title": "Whether the environment variables automatically discovered by the CI runner are skipped from the attestation" - } - } - }, - "v1OrgMembershipItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "org": { - "$ref": "#/definitions/v1OrgItem" - }, - "user": { - "$ref": "#/definitions/v1User" - }, - "current": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "role": { - "$ref": "#/definitions/v1MembershipRole" - } - } - }, - "v1OrgMetricsServiceTotalsResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgMetricsServiceTotalsResponseResult" - } - } - }, - "v1OrgMetricsServiceTotalsResponseResult": { - "type": "object", - "properties": { - "runs_total": { - "type": "integer", - "format": "int32" - }, - "runs_total_by_status": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1MetricsStatusCount" - } - }, - "runs_total_by_runner_type": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1MetricsRunnerCount" - } - } - } - }, - "v1OrganizationServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgItem" - } - } - }, - "v1OrganizationServiceDeleteMembershipResponse": { - "type": "object" - }, - "v1OrganizationServiceDeleteResponse": { - "type": "object" - }, - "v1OrganizationServiceListMembershipsResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1OrgMembershipItem" - } - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - } - }, - "v1OrganizationServiceUpdateMembershipResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgMembershipItem" - } - } - }, - "v1OrganizationServiceUpdateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgItem" - } - } - }, - "v1PendingGroupInvitation": { - "type": "object", - "properties": { - "user_email": { - "type": "string", - "title": "The email address of the user invited to the group" - }, - "invited_by": { - "$ref": "#/definitions/v1User", - "title": "The user who sent the invitation" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the invitation was created" - }, - "invitation_id": { - "type": "string", - "title": "Unique identifier for the invitation" - } - }, - "title": "PendingInvitation represents an invitation to join a group that has not yet been accepted" - }, - "v1PendingProjectInvitation": { - "type": "object", - "properties": { - "user_email": { - "type": "string", - "title": "The email address of the user invited to the project" - }, - "invited_by": { - "$ref": "#/definitions/v1User", - "title": "The user who sent the invitation" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the invitation was created" - }, - "invitation_id": { - "type": "string", - "title": "Unique identifier for the invitation" - } - }, - "title": "PendingInvitation represents an invitation to join a project that has not yet been accepted" - }, - "v1PluginFanout": { - "type": "object", - "properties": { - "registration_schema": { - "type": "string", - "format": "byte", - "title": "Registration JSON schema" - }, - "attachment_schema": { - "type": "string", - "format": "byte", - "title": "Attachment JSON schema" - }, - "subscribed_materials": { - "type": "array", - "items": { - "type": "string" - }, - "title": "List of materials that the integration is subscribed to" - } - }, - "title": "PluginFanout describes a plugin that can be used to fanout attestation and materials to multiple integrations" - }, - "v1Policies": { - "type": "object", - "properties": { - "materials": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyAttachment" - }, - "title": "Policies to be applied to materials" - }, - "attestation": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyAttachment" - }, - "title": "Policies to be applied to attestation metadata" - } - } - }, - "v1Policy": { - "type": "object", - "properties": { - "api_version": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/definitions/v1Metadata" - }, - "spec": { - "$ref": "#/definitions/v1PolicySpec" - } - }, - "title": "Represents a policy to be applied to a material or attestation" - }, - "v1PolicyAssessment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Platform assessment UUID." - }, - "status": { - "type": "string", - "description": "Platform AssessmentStatus enum name, e.g. \"ASSESSMENT_STATUS_NOT_AFFECTED\"." - }, - "scope": { - "type": "string", - "description": "Platform AssessmentScope enum name, e.g. \"ASSESSMENT_SCOPE_PROJECT\"." - } - }, - "description": "Audit-critical subset of an assessment. Full record is reachable via\nAssessmentService.Get on the platform using `id`." - }, - "v1PolicyAssessmentResult": { - "type": "object", - "properties": { - "effective_status": { - "type": "string", - "description": "Precedence-resolved status across `assessments`. Values use the\nplatform's AssessmentStatus enum names, e.g.\n\"ASSESSMENT_STATUS_NOT_AFFECTED\"." - }, - "assessments": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyAssessment" - }, - "description": "Assessments matching the finding's identity tuple." - } - }, - "description": "Assessment context attached to a policy finding by the policy engine via\nthe chainloop.effective_assessments builtin. Sent to CAS as part of the\nPolicyEvaluationBundle." - }, - "v1PolicyAttachment": { - "type": "object", - "properties": { - "ref": { - "type": "string", - "description": "policy reference, it might be in URI format." - }, - "embedded": { - "$ref": "#/definitions/v1Policy", - "title": "meant to be used to embed the policy in the contract" - }, - "selector": { - "$ref": "#/definitions/PolicyAttachmentMaterialSelector", - "title": "rules to select a material or materials to be validated by the policy.\nIf none provided, the whole statement will be injected to the policy" - }, - "disabled": { - "type": "boolean", - "title": "set to true to disable this rule" - }, - "with": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "optional arguments for policies. Multivalued arguments can be set through multiline strings or comma separated values. It will be\nparsed and passed as an array value to the policy engine.\nwith:\n user: john\n users: john, sarah\n licenses: |\n AGPL-1.0\n AGPL-3.0" - }, - "requirements": { - "type": "array", - "items": { - "type": "string" - }, - "title": "List of requirements this policy contributes to satisfy" - }, - "gate": { - "type": "boolean", - "title": "Controls whether policy violations act as a gate.\n- true: policy violations are blocking for this policy\n- false: policy violations are non-blocking for this policy\n- unset: inherit organization-level default behavior" - } - }, - "title": "A policy to be applied to this contract" - }, - "v1PolicyEvaluationViolation": { - "type": "object", - "properties": { - "subject": { - "type": "string" - }, - "message": { - "type": "string" - }, - "vulnerability": { - "$ref": "#/definitions/v1PolicyVulnerabilityFinding" - }, - "sast": { - "$ref": "#/definitions/v1PolicySASTFinding" - }, - "license_violation": { - "$ref": "#/definitions/v1PolicyLicenseViolationFinding" - }, - "suppress": { - "type": "boolean", - "description": "Suppression hint set by the policy. When true the gate count\nexcludes this entry, but it is still stored in CAS and ingested\n(audit trail preserved). Set by the policy author (typically based\non `assessment.effective_status`) — the engine reads the bool\nwithout interpreting why." - } - } - }, - "v1PolicyEvaluations": { - "type": "object", - "properties": { - "evaluations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/controlplanev1PolicyEvaluation" - } - } - } - }, - "v1PolicyEvaluationsRef": { - "type": "object", - "properties": { - "digest": { - "type": "string", - "title": "Digest of the bundle as stored in CAS, in \"sha256:\u003chex\u003e\" form" - }, - "size_bytes": { - "type": "string", - "format": "int64", - "description": "Size of the bundle in bytes. Zero when the size could not be determined." - }, - "media_type": { - "type": "string", - "title": "Media type of the bundle, i.e application/vnd.chainloop.policy-evaluations.v1+json" - }, - "reason": { - "$ref": "#/definitions/PolicyEvaluationsRefReason", - "description": "Why the evaluations were not inlined. REASON_UNSPECIFIED when they were." - }, - "inlined": { - "type": "boolean", - "description": "True when policy_evaluations carries the evaluations decoded from this\nbundle, i.e the reference is informational rather than a fallback." - } - }, - "description": "Pointer to a policy-evaluation bundle held in a CAS backend. It is the\ncanonical location of the evaluations, returned both alongside them and, when\nthey could not be inlined, in their place." - }, - "v1PolicyGatesFilter": { - "type": "string", - "enum": [ - "POLICY_GATES_FILTER_UNSPECIFIED", - "POLICY_GATES_FILTER_WITH_GATES", - "POLICY_GATES_FILTER_WITHOUT_GATES" - ], - "default": "POLICY_GATES_FILTER_UNSPECIFIED", - "description": "Server-side filter aligned with PolicyStatusSummary.has_gates." - }, - "v1PolicyGroup": { - "type": "object", - "properties": { - "api_version": { - "type": "string" - }, - "kind": { - "type": "string" - }, - "metadata": { - "$ref": "#/definitions/v1Metadata" - }, - "spec": { - "$ref": "#/definitions/PolicyGroupPolicyGroupSpec" - } - }, - "title": "Represents a group or policies" - }, - "v1PolicyGroupAttachment": { - "type": "object", - "properties": { - "ref": { - "type": "string", - "title": "Group reference, it might be an URL or a provider reference" - }, - "with": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "group arguments" - }, - "skip": { - "type": "array", - "items": { - "type": "string" - }, - "title": "policy names to skip (matched against metadata.name)" - }, - "gate": { - "type": "boolean", - "title": "Controls whether policy violations act as a gate for this group.\n- true: policy violations are blocking for this policy group\n- false: policy violations are non-blocking for this policy group\n- unset: inherit organization-level default behavior" - } - }, - "title": "Represents a group attachment in a contract" - }, - "v1PolicyGroupMaterial": { - "type": "object", - "properties": { - "type": { - "$ref": "#/definitions/MaterialMaterialType" - }, - "name": { - "type": "string", - "title": "Free form name, as we support placeholders eg `{{ inputs.input_name }}`\nIf no name is provided, material won't be enforced and will apply policies if `type` matches" - }, - "optional": { - "type": "boolean" - }, - "match_mode": { - "$ref": "#/definitions/MaterialSelectorMatchMode", - "description": "How `name` is matched against a material's name. Defaults to exact match\n(UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\na name prefix, mirroring PolicyAttachment.MaterialSelector." - }, - "policies": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyAttachment" - }, - "title": "Policies to be applied to this material" - } - }, - "title": "Policy group materials" - }, - "v1PolicyInput": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "default": { - "type": "string" - } - } - }, - "v1PolicyLicenseViolationFinding": { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "Human-readable violation description" - }, - "component_name": { - "type": "string", - "title": "Component name (e.g., \"lodash\")" - }, - "package_purl": { - "type": "string", - "title": "Package URL (e.g., pkg:npm/lodash@4.17.21)" - }, - "license_id": { - "type": "string", - "title": "License identifier (e.g., \"GPL-3.0\", \"AGPL-3.0\")" - }, - "license_name": { - "type": "string", - "title": "Human-readable license name" - }, - "component_version": { - "type": "string", - "title": "Component version" - }, - "recommendation": { - "type": "string", - "title": "Suggested fix" - }, - "assessment": { - "$ref": "#/definitions/v1PolicyAssessmentResult", - "description": "Optional assessment context. See PolicyAssessmentResult." - } - }, - "description": "Output schema for license violation findings from policy evaluation.\nUsed when a policy declares finding_type: LICENSE_VIOLATION." - }, - "v1PolicyReference": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "digest": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "organization": { - "type": "string" - }, - "uri": { - "type": "string" - } - } - }, - "v1PolicySASTFinding": { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "Human-readable violation description" - }, - "rule_id": { - "type": "string", - "title": "Tool-specific rule identifier (e.g., java:S1234, go-sec:G101)" - }, - "severity": { - "type": "string", - "title": "Severity level (CRITICAL, HIGH, MEDIUM, LOW)" - }, - "location": { - "type": "string", - "title": "File path where the issue was found" - }, - "line_number": { - "type": "integer", - "format": "int32", - "title": "Line number in the file" - }, - "code_snippet": { - "type": "string", - "title": "Code snippet showing the issue" - }, - "recommendation": { - "type": "string", - "title": "Suggested fix" - }, - "severity_score": { - "type": "number", - "format": "double", - "title": "Optional numeric severity score from the scanner (scale is tool-defined)" - }, - "assessment": { - "$ref": "#/definitions/v1PolicyAssessmentResult", - "description": "Optional assessment context. See PolicyAssessmentResult." - } - }, - "description": "Output schema for SAST findings from policy evaluation.\nUsed when a policy declares finding_type: SAST." - }, - "v1PolicySpec": { - "type": "object", - "properties": { - "path": { - "type": "string", - "title": "path to a policy script. It might consist of a URI reference" - }, - "embedded": { - "type": "string", - "title": "embedded source code (only Rego supported currently)" - }, - "type": { - "$ref": "#/definitions/MaterialMaterialType", - "description": "if set, it will match any material supported by Chainloop\nexcept those not having a direct schema (STRING, ARTIFACT, EVIDENCE), since their format cannot be guessed by the crafter.\nCONTAINER, HELM_CHART are also excluded, but we might implement custom policies for them in the future." - }, - "policies": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicySpecV2" - } - }, - "inputs": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PolicyInput" - }, - "title": "Describe the supported inputs" - }, - "auto_match": { - "$ref": "#/definitions/v1AutoMatch" - } - } - }, - "v1PolicySpecV2": { - "type": "object", - "properties": { - "path": { - "type": "string", - "title": "path to a policy script. It might consist of a URI reference\ndeprecated: use ref instead" - }, - "embedded": { - "type": "string", - "title": "embedded source code (only Rego supported currently)" - }, - "ref": { - "type": "string", - "title": "generic reference for file:// and http(s):// schemes" - }, - "kind": { - "$ref": "#/definitions/MaterialMaterialType", - "title": "if set, it will match any material supported by Chainloop" - }, - "attestation_phases": { - "type": "array", - "items": { - "$ref": "#/definitions/v1AttestationPhase" - }, - "description": "Controls at which attestation phases this policy is evaluated.\nEmpty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\nOnly applicable when kind is ATTESTATION." - } - } - }, - "v1PolicyStatus": { - "type": "string", - "enum": [ - "POLICY_STATUS_UNSPECIFIED", - "POLICY_STATUS_NOT_APPLICABLE", - "POLICY_STATUS_PASSED", - "POLICY_STATUS_SKIPPED", - "POLICY_STATUS_WARNING", - "POLICY_STATUS_BLOCKED", - "POLICY_STATUS_BYPASSED" - ], - "default": "POLICY_STATUS_UNSPECIFIED", - "description": "Canonical, server-computed categorical policy outcome for an attestation.\nCollapses the raw enforcement/bypass/violation signals on\nAttestationItem.PolicyEvaluationStatus into a single flat value so that\nlist and describe surfaces can render a consistent badge without\nre-deriving.\n\n - POLICY_STATUS_NOT_APPLICABLE: No policies were evaluated on this run\n - POLICY_STATUS_PASSED: Policies ran with no violations and no skips\n - POLICY_STATUS_SKIPPED: No violations but at least one evaluation was skipped\n - POLICY_STATUS_WARNING: Has violations but enforcement is advisory — run succeeded\n - POLICY_STATUS_BLOCKED: Has gated violations or enforced strategy with violations; not bypassed\n - POLICY_STATUS_BYPASSED: Enforcement would have blocked the run but was bypassed" - }, - "v1PolicyStatusFilter": { - "type": "string", - "enum": [ - "POLICY_STATUS_FILTER_UNSPECIFIED", - "POLICY_STATUS_FILTER_NOT_APPLICABLE", - "POLICY_STATUS_FILTER_PASSED", - "POLICY_STATUS_FILTER_SKIPPED", - "POLICY_STATUS_FILTER_WARNING", - "POLICY_STATUS_FILTER_BLOCKED", - "POLICY_STATUS_FILTER_BYPASSED" - ], - "default": "POLICY_STATUS_FILTER_UNSPECIFIED", - "description": "Server-side filter aligned 1:1 with PolicyStatus values." - }, - "v1PolicyStatusSummary": { - "type": "object", - "properties": { - "status": { - "$ref": "#/definitions/v1PolicyStatus" - }, - "total": { - "type": "integer", - "format": "int32", - "title": "Total number of policy evaluations that ran for this attestation" - }, - "passed": { - "type": "integer", - "format": "int32", - "title": "Number of evaluations with no violations and not skipped" - }, - "skipped": { - "type": "integer", - "format": "int32", - "title": "Number of evaluations that were skipped" - }, - "violated": { - "type": "integer", - "format": "int32", - "title": "Total number of violations across all evaluations" - }, - "has_gates": { - "type": "boolean", - "description": "Whether this run had gates in effect — any policy marked gate:true or\nthe contract using the ENFORCED blocking strategy. Independent of status:\na PASSED run can still have has_gates=true." - }, - "suppressed": { - "type": "integer", - "format": "int32", - "description": "Number of suppressed violations across all evaluations. Excluded\nfrom violated; kept in the CAS audit trail." - } - }, - "description": "PolicyStatusSummary bundles the canonical PolicyStatus with per-evaluation\ncounters. It is surfaced on both WorkflowRunItem (list response) and\nAttestationItem.PolicyEvaluationStatus (describe response) and is computed\nby a single backend helper so list and describe cannot drift." - }, - "v1PolicyViolation": { - "type": "object", - "properties": { - "subject": { - "type": "string" - }, - "message": { - "type": "string" - }, - "suppress": { - "type": "boolean", - "description": "Whether this violation was excluded from the policy gate (still kept\nin the CAS audit trail)." - }, - "vulnerability": { - "$ref": "#/definitions/v1PolicyVulnerabilityFinding" - }, - "sast": { - "$ref": "#/definitions/v1PolicySASTFinding" - }, - "license_violation": { - "$ref": "#/definitions/v1PolicyLicenseViolationFinding" - } - } - }, - "v1PolicyViolationsFilter": { - "type": "string", - "enum": [ - "POLICY_VIOLATIONS_FILTER_UNSPECIFIED", - "POLICY_VIOLATIONS_FILTER_WITH_VIOLATIONS", - "POLICY_VIOLATIONS_FILTER_WITHOUT_VIOLATIONS" - ], - "default": "POLICY_VIOLATIONS_FILTER_UNSPECIFIED", - "description": "Deprecated: use PolicyStatusFilter which aligns 1:1 with PolicyStatus and\nlets callers distinguish warning/blocked/bypassed from the coarse with/\nwithout-violations split." - }, - "v1PolicyVulnerabilityFinding": { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "Human-readable violation description" - }, - "external_id": { - "type": "string", - "title": "Vulnerability identifier (e.g., CVE-2024-1234, GHSA-xxxx)" - }, - "package_purl": { - "type": "string", - "title": "Package URL of the affected component (e.g., pkg:golang/example.com/lib@v1.0.0)" - }, - "severity": { - "type": "string", - "title": "Severity level (CRITICAL, HIGH, MEDIUM, LOW)" - }, - "cvss_v3_score": { - "type": "number", - "format": "double", - "title": "CVSS v3 score (0.0-10.0)" - }, - "cwes": { - "type": "array", - "items": { - "type": "string" - }, - "title": "CWE references (e.g., CWE-79, CWE-89)" - }, - "recommendation": { - "type": "string", - "title": "Suggested fix or upgrade path" - }, - "description": { - "type": "string", - "title": "Optional longer description of the vulnerability" - }, - "fixed_version": { - "type": "string", - "title": "Version that fixes the vulnerability (e.g., \"2.0.1\", \"1.3.4-patch1\")" - }, - "assessment": { - "$ref": "#/definitions/v1PolicyAssessmentResult", - "description": "Optional assessment context. See PolicyAssessmentResult." - } - }, - "description": "Output schema for vulnerability findings from policy evaluation.\nUsed when a policy declares finding_type: VULNERABILITY." - }, - "v1ProjectMember": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User", - "title": "The user who is a member of the project" - }, - "group": { - "$ref": "#/definitions/v1Group", - "title": "The group who is a member of the project" - }, - "role": { - "$ref": "#/definitions/v1ProjectMemberRole", - "title": "The role of the user in the project" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the project membership was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Timestamp when the project membership was last modified" - }, - "latest_project_version_id": { - "type": "string", - "title": "The ID of latest project version this member is associated with" - }, - "parent_id": { - "type": "string", - "title": "Optional parent resource ID for nested project memberships" - } - }, - "title": "ProjectMember represents an user or group who is a member of a project" - }, - "v1ProjectMemberRole": { - "type": "string", - "enum": [ - "PROJECT_MEMBER_ROLE_UNSPECIFIED", - "PROJECT_MEMBER_ROLE_ADMIN", - "PROJECT_MEMBER_ROLE_VIEWER" - ], - "default": "PROJECT_MEMBER_ROLE_UNSPECIFIED", - "description": "- PROJECT_MEMBER_ROLE_UNSPECIFIED: Default role for a project member\n - PROJECT_MEMBER_ROLE_ADMIN: Admin role for a project member\n - PROJECT_MEMBER_ROLE_VIEWER: Viewer role for a project member", - "title": "ProjectMemberRole defines the roles a member can have in a project" - }, - "v1ProjectMembershipReference": { - "type": "object", - "properties": { - "user_email": { - "type": "string", - "title": "The user to add to the project" - }, - "group_reference": { - "$ref": "#/definitions/v1IdentityReference", - "title": "The group to add to the project" - } - }, - "title": "ProjectMembershipReference is used to reference a user or group in the context of project membership" - }, - "v1ProjectServiceAddMemberResponse": { - "type": "object", - "title": "ProjectServiceAddMemberResponse contains the result of adding a user to a project" - }, - "v1ProjectServiceListMembersResponse": { - "type": "object", - "properties": { - "members": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ProjectMember" - }, - "title": "The list of members in the project" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - }, - "title": "ProjectServiceListMembersResponse contains the list of members in a project" - }, - "v1ProjectServiceListPendingInvitationsResponse": { - "type": "object", - "properties": { - "invitations": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PendingProjectInvitation" - }, - "title": "List of pending invitations for the project" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - } - }, - "title": "ProjectServiceListPendingInvitationsResponse contains a list of pending invitations for a project" - }, - "v1ProjectServiceListResponse": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/ProjectServiceListResponseProjectItem" - }, - "title": "List of projects matching the request criteria" - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse", - "title": "Pagination information for the response" - }, - "can_create_project": { - "type": "boolean", - "description": "Whether the caller may create a project in this organization. It is a\nproperty of their organization role, not of any project, so it does not\nbelong on the items: an organization contributor, for instance, can be an\nadministrator of several projects and still create none. Clients offering\nto create one need this to avoid an option that always fails." - } - }, - "title": "ProjectServiceListResponse contains a paginated list of the organization's projects" - }, - "v1ProjectServiceRemoveMemberResponse": { - "type": "object", - "title": "ProjectServiceRemoveMemberResponse is returned upon successful removal of a user from a project" - }, - "v1ProjectServiceUpdateMemberRoleResponse": { - "type": "object", - "title": "ProjectServiceUpdateMemberRoleResponse is returned upon successful update of a member's role in a project" - }, - "v1ReferrerEdge": { - "type": "object", - "properties": { - "from": { - "type": "integer", - "format": "int64", - "title": "Index of the lower end of the connection, always smaller than to" - }, - "to": { - "type": "integer", - "format": "int64", - "title": "Index of the higher end of the connection" - } - }, - "description": "A connection between two referrers, as indexes into the request", - "title": "ReferrerEdge" - }, - "v1ReferrerItem": { - "type": "object", - "properties": { - "digest": { - "type": "string", - "title": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead" - }, - "kind": { - "type": "string", - "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD, ..." - }, - "downloadable": { - "type": "boolean", - "title": "Downloadable indicates whether the referrer is downloadable or not from CAS" - }, - "references": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ReferrerItem" - }, - "title": "References contains the list of related referrer items" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "CreatedAt is the timestamp when the referrer was created" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Metadata contains additional descriptive information about the referrer" - }, - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Annotations are key-value pairs associated with the referrer" - } - }, - "description": "It represents a referrer object in the system", - "title": "ReferrerItem" - }, - "v1ReferrerRef": { - "type": "object", - "properties": { - "digest": { - "type": "string", - "title": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead" - }, - "kind": { - "type": "string", - "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ..." - } - }, - "description": "A reference to a referrer by digest and kind", - "title": "ReferrerRef" - }, - "v1ReferrerServiceDiscoverEdgesRequest": { - "type": "object", - "properties": { - "nodes": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ReferrerRef" - }, - "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\nthe kind travels with it.\n\nThis is a set. A referrer may be listed more than once — a client that collected referrers\nfrom several attestations will have repeats — and is then reported at the position it first\nappeared in. At least two distinct referrers are required." - }, - "project_name": { - "type": "string", - "title": "Restricts the answer to a project, the same way DiscoverPrivate does" - }, - "project_version": { - "type": "string", - "title": "Only meaningful alongside project_name: version names are unique within a project" - } - }, - "description": "Request to discover the edges between a set of referrers", - "title": "ReferrerServiceDiscoverEdgesRequest" - }, - "v1ReferrerServiceDiscoverEdgesResponse": { - "type": "object", - "properties": { - "edges": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ReferrerEdge" - }, - "title": "Each connection once, regardless of the direction it is stored in, and referred to by the\nfirst position its referrers were listed under in the request" - } - }, - "description": "Response for the DiscoverEdges method", - "title": "ReferrerServiceDiscoverEdgesResponse" - }, - "v1ReferrerServiceDiscoverPrivateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1ReferrerItem", - "title": "Result is the discovered referrer item" - }, - "pagination": { - "$ref": "#/definitions/v1CursorPaginationResponse", - "title": "Pagination information for the references list" - } - }, - "description": "Response for the DiscoverPrivate method", - "title": "ReferrerServiceDiscoverPrivateResponse" - }, - "v1RegisteredIntegrationItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string", - "title": "unique, DNS-like name for the registration" - }, - "kind": { - "type": "string" - }, - "description": { - "type": "string", - "title": "Description of the registration, used for display purposes" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "config": { - "type": "string", - "format": "byte", - "title": "Arbitrary configuration for the integration" - } - } - }, - "v1RemotePolicyReference": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "digest": { - "type": "string" - } - } - }, - "v1RunStatus": { - "type": "string", - "enum": [ - "RUN_STATUS_UNSPECIFIED", - "RUN_STATUS_INITIALIZED", - "RUN_STATUS_SUCCEEDED", - "RUN_STATUS_FAILED", - "RUN_STATUS_EXPIRED", - "RUN_STATUS_CANCELLED" - ], - "default": "RUN_STATUS_UNSPECIFIED" - }, - "v1RunnerEnvironment": { - "type": "object", - "properties": { - "workflow_file_path": { - "type": "string", - "title": "Workflow file path that was used during build" - }, - "environment": { - "type": "string", - "title": "Runner environment name, i.e. github-hosted" - }, - "authenticated": { - "type": "boolean", - "title": "Whether the runner is authenticated, i.e. via the OIDC token" - }, - "type": { - "$ref": "#/definitions/RunnerRunnerType", - "title": "Runner type" - }, - "url": { - "type": "string", - "title": "Runner URL" - } - }, - "title": "The runner environment in which the attestation was crafted" - }, - "v1ScopedEntity": { - "type": "object", - "properties": { - "type": { - "type": "string", - "title": "Type is the type of the scoped entity i.e project or org" - }, - "id": { - "type": "string", - "title": "ID is the id of the scoped entity" - }, - "name": { - "type": "string", - "title": "Name is the name of the scoped entity" - } - } - }, - "v1SetCurrentMembershipResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1OrgMembershipItem" - } - } - }, - "v1StatuszResponse": { - "type": "object" - }, - "v1TopWorkflowsByRunsCountResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/TopWorkflowsByRunsCountResponseTotalByStatus" - } - } - } - }, - "v1User": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "email": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "first_name": { - "type": "string" - }, - "last_name": { - "type": "string" - }, - "instance_admin": { - "type": "boolean" - } - } - }, - "v1UserServiceListMembershipsResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1OrgMembershipItem" - } - } - } - }, - "v1WorkflowActivityWindow": { - "type": "string", - "enum": [ - "WORKFLOW_ACTIVITY_WINDOW_UNSPECIFIED", - "WORKFLOW_ACTIVITY_WINDOW_LAST_DAY", - "WORKFLOW_ACTIVITY_WINDOW_LAST_7_DAYS", - "WORKFLOW_ACTIVITY_WINDOW_LAST_30_DAYS" - ], - "default": "WORKFLOW_ACTIVITY_WINDOW_UNSPECIFIED", - "description": "WorkflowActivityWindow represents the time window for the last known workflow activity." - }, - "v1WorkflowContractItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "title": "deprecated: description now belongs to the version, as it's versioned since the introduction of new contracts v2" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "latest_revision": { - "type": "integer", - "format": "int32" - }, - "latest_revision_created_at": { - "type": "string", - "format": "date-time" - }, - "workflow_names": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Workflows associated with this contract" - }, - "workflow_refs": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1WorkflowRef" - } - }, - "scoped_entity": { - "$ref": "#/definitions/v1ScopedEntity", - "title": "wether the contract is scoped to an entity in the organization" - } - } - }, - "v1WorkflowContractServiceApplyResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowContractItem" - }, - "unchanged": { - "type": "boolean", - "title": "Deprecated: use changed instead" - }, - "changed": { - "type": "boolean", - "title": "Whether the resource was changed" - }, - "status": { - "$ref": "#/definitions/WorkflowContractServiceApplyResponseApplyStatus", - "title": "Detailed outcome of the apply operation" - }, - "current_revision": { - "type": "integer", - "format": "int32", - "description": "Current revision of the contract after the apply.\nFor a newly created contract this is the first revision; for an\nunchanged contract it stays at the existing revision; for dry runs it\nreflects the existing revision (0 when the contract does not exist yet)." - } - } - }, - "v1WorkflowContractServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowContractItem" - } - } - }, - "v1WorkflowContractServiceDeleteResponse": { - "type": "object" - }, - "v1WorkflowContractServiceDescribeResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowContractServiceDescribeResponseResult" - } - } - }, - "v1WorkflowContractServiceDescribeResponseResult": { - "type": "object", - "properties": { - "contract": { - "$ref": "#/definitions/v1WorkflowContractItem" - }, - "revision": { - "$ref": "#/definitions/v1WorkflowContractVersionItem" - } - } - }, - "v1WorkflowContractServiceListResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1WorkflowContractItem" - } - } - } - }, - "v1WorkflowContractServiceUpdateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowContractServiceUpdateResponseResult" - } - } - }, - "v1WorkflowContractServiceUpdateResponseResult": { - "type": "object", - "properties": { - "contract": { - "$ref": "#/definitions/v1WorkflowContractItem" - }, - "revision": { - "$ref": "#/definitions/v1WorkflowContractVersionItem" - } - } - }, - "v1WorkflowContractVersionItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "revision": { - "type": "integer", - "format": "int32" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "v1": { - "$ref": "#/definitions/v1CraftingSchema", - "title": "Deprecated in favor of raw_contract" - }, - "raw_contract": { - "$ref": "#/definitions/WorkflowContractVersionItemRawBody" - }, - "contract_name": { - "type": "string", - "title": "The name of the contract" - }, - "description": { - "type": "string", - "title": "The contract version description" - } - } - }, - "v1WorkflowItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "project": { - "type": "string" - }, - "project_id": { - "type": "string" - }, - "team": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "runs_count": { - "type": "integer", - "format": "int32" - }, - "last_run": { - "$ref": "#/definitions/v1WorkflowRunItem" - }, - "contract_name": { - "type": "string" - }, - "contract_revision_latest": { - "type": "integer", - "format": "int32", - "title": "Current, latest revision of the contract" - }, - "public": { - "type": "boolean", - "description": "Deprecated: public workflows were removed. This field is always false and\nis retained only for wire compatibility; it will be removed in a future release." - }, - "description": { - "type": "string" - }, - "workflow_template_id": { - "type": "string", - "description": "Optional reference to the platform workflow template this workflow was created from.\nEmpty when the workflow is not bound to any template." - } - } - }, - "v1WorkflowMetadata": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "project": { - "type": "string" - }, - "project_version": { - "type": "string", - "title": "kept for backwards compatibility with remote state storage" - }, - "version": { - "$ref": "#/definitions/attestationv1ProjectVersion", - "title": "project version" - }, - "team": { - "type": "string" - }, - "workflow_id": { - "type": "string" - }, - "workflow_run_id": { - "type": "string", - "title": "Not required since we might be doing a dry-run" - }, - "schema_revision": { - "type": "string" - }, - "contract_name": { - "type": "string", - "title": "contract name (contract version is \"schema_revision\")" - }, - "organization": { - "type": "string", - "title": "organization name" - } - } - }, - "v1WorkflowRef": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "project_name": { - "type": "string" - } - } - }, - "v1WorkflowRunItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "state": { - "type": "string", - "title": "TODO: use runStatus enum below\ndeprecated field, use status instead" - }, - "status": { - "$ref": "#/definitions/v1RunStatus" - }, - "reason": { - "type": "string" - }, - "workflow": { - "$ref": "#/definitions/v1WorkflowItem" - }, - "job_url": { - "type": "string" - }, - "runner_type": { - "$ref": "#/definitions/RunnerRunnerType", - "title": "string runner_type = 8;" - }, - "contract_version": { - "$ref": "#/definitions/v1WorkflowContractVersionItem" - }, - "contract_revision_used": { - "type": "integer", - "format": "int32", - "title": "The revision of the contract used for this run" - }, - "contract_revision_latest": { - "type": "integer", - "format": "int32", - "title": "The latest revision available for this contract at the time of the run" - }, - "version": { - "$ref": "#/definitions/controlplanev1ProjectVersion", - "title": "The version of the project the attestation was initiated with" - }, - "has_policy_violations": { - "type": "boolean", - "description": "Whether the run has policy violations (null if no policies were evaluated)\nDeprecated: use policy_summary.violated \u003e 0, or the richer\npolicy_summary.status, which also distinguishes skipped / warning /\nblocked / bypassed outcomes." - }, - "policy_summary": { - "$ref": "#/definitions/v1PolicyStatusSummary", - "description": "Canonical policy status summary for this run (null if no policies were\nevaluated). Carries both the categorical PolicyStatus and per-evaluation\ncounters so list consumers can render a badge without calling View." - } - } - }, - "v1WorkflowRunServiceListResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1WorkflowRunItem" - } - }, - "pagination": { - "$ref": "#/definitions/v1CursorPaginationResponse" - } - } - }, - "v1WorkflowRunServiceViewResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowRunServiceViewResponseResult" - } - } - }, - "v1WorkflowRunServiceViewResponseResult": { - "type": "object", - "properties": { - "org_name": { - "type": "string" - }, - "workflow_run": { - "$ref": "#/definitions/v1WorkflowRunItem" - }, - "attestation": { - "$ref": "#/definitions/v1AttestationItem" - }, - "verification": { - "$ref": "#/definitions/WorkflowRunServiceViewResponseVerificationResult", - "title": "It will be nil if the verification is not possible (old or non-keyless attestations)" - } - } - }, - "v1WorkflowServiceCreateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowItem" - } - } - }, - "v1WorkflowServiceDeleteResponse": { - "type": "object" - }, - "v1WorkflowServiceListResponse": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1WorkflowItem" - } - }, - "pagination": { - "$ref": "#/definitions/v1OffsetPaginationResponse" - } - } - }, - "v1WorkflowServiceUpdateResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowItem" - } - } - }, - "v1WorkflowServiceViewResponse": { - "type": "object", - "properties": { - "result": { - "$ref": "#/definitions/v1WorkflowItem" - } - } - }, - "workflowcontractv1Annotation": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Single word optionally separated with _" - }, - "value": { - "type": "string", - "title": "This value can be set in the contract or provided during the attestation" - } - } - } - }, - "securityDefinitions": { - "bearerToken": { - "type": "apiKey", - "description": "Bearer token for authentication", - "name": "Authorization", - "in": "header" - } - }, - "security": [ - { - "bearerToken": [] - } - ], - "externalDocs": { - "description": "Chainloop Official Documentation", - "url": "https://docs.chainloop.dev" - } -} diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 48746be27..5b2c4c0ac 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -847,11 +847,12 @@ type PolicyAttachment struct { // optional arguments for policies. Multivalued arguments can be set through multiline strings or comma separated values. It will be // parsed and passed as an array value to the policy engine. // with: - // user: john - // users: john, sarah - // licenses: | - // AGPL-1.0 - // AGPL-3.0 + // + // user: john + // users: john, sarah + // licenses: | + // AGPL-1.0 + // AGPL-3.0 With map[string]string `protobuf:"bytes,5,rep,name=with,proto3" json:"with,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // List of requirements this policy contributes to satisfy Requirements []string `protobuf:"bytes,6,rep,name=requirements,proto3" json:"requirements,omitempty"` @@ -1051,9 +1052,10 @@ type Metadata struct { // Declares the structured output schema for policy violations. // When set, the policy engine validates that violations conform to the // corresponding proto message: - // VULNERABILITY -> attestation.v1.PolicyVulnerabilityFinding - // SAST -> attestation.v1.PolicySASTFinding - // LICENSE_VIOLATION -> attestation.v1.PolicyLicenseViolationFinding + // + // VULNERABILITY -> attestation.v1.PolicyVulnerabilityFinding + // SAST -> attestation.v1.PolicySASTFinding + // LICENSE_VIOLATION -> attestation.v1.PolicyLicenseViolationFinding FindingType *string `protobuf:"bytes,7,opt,name=finding_type,json=findingType,proto3,oneof" json:"finding_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache From 8c32a49cd692c215f2eee517c56050200342de8e Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 12:30:14 +0200 Subject: [PATCH 12/15] test(controlplane): assert the organization count the filter is named for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table calls a field wantOrgs and documents it as the number of organizations expected in the filter's IN list, but only ever used it to decide whether to look for the list at all. The assertion was a substring check, so the case built from 23 organizations passed for a list holding one — the property the test exists to guard was never checked. Values are bound, so the list is counted by its placeholders. The total bound count is asserted too: one value per organization and a pair per granted project, and nothing else. Also removes ProjectVisibilityPredicateForTest. Nothing reads it, and a test bridge is worth only what it lets an external test reach. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/data/export_test.go | 9 ++---- app/controlplane/pkg/data/referrer_test.go | 35 +++++++++++++++++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/controlplane/pkg/data/export_test.go b/app/controlplane/pkg/data/export_test.go index 7ba148563..6119e652c 100644 --- a/app/controlplane/pkg/data/export_test.go +++ b/app/controlplane/pkg/data/export_test.go @@ -16,10 +16,7 @@ package data // Internals exposed to the package's external tests. This file is only compiled during tests, so -// it does not widen the package's API. The visibility filters are reached from data_test because -// asserting on them against a real database needs the shared suite in biz/testhelpers, which +// it does not widen the package's API. The visibility filter is reached from data_test because +// asserting on it against a real database needs the shared suite in biz/testhelpers, which // imports this package and therefore cannot be imported from inside it. -var ( - ReferrerVisibleToOrgsForTest = referrerVisibleToOrgs - ProjectVisibilityPredicateForTest = projectVisibilityPredicate -) +var ReferrerVisibleToOrgsForTest = referrerVisibleToOrgs diff --git a/app/controlplane/pkg/data/referrer_test.go b/app/controlplane/pkg/data/referrer_test.go index 9f85640d5..e23a11f45 100644 --- a/app/controlplane/pkg/data/referrer_test.go +++ b/app/controlplane/pkg/data/referrer_test.go @@ -44,6 +44,24 @@ func renderProjectPredicate(t *testing.T, p func(*sql.Selector)) (string, []stri return q, bound } +// boundOrganizations counts the values the organization IN list binds, or zero when the filter +// carries no such list. The pair list reads ("organization_id", "id") IN (...), so it cannot be +// mistaken for this one. +func boundOrganizations(query string) int { + const list = `"projects"."organization_id" IN (` + start := strings.Index(query, list) + if start < 0 { + return 0 + } + rest := query[start+len(list):] + end := strings.Index(rest, ")") + if end < 0 { + return 0 + } + + return strings.Count(rest[:end], "$") +} + func TestProjectVisibilityPredicate(t *testing.T) { orgs := make([]uuid.UUID, 0, 23) for i := 0; i < 23; i++ { @@ -114,14 +132,23 @@ func TestProjectVisibilityPredicate(t *testing.T) { } require.NotNil(t, got) - query, _ := renderProjectPredicate(t, got) + query, args := renderProjectPredicate(t, got) // A condition per organization is the regression this guards against. assert.Equal(t, tc.wantExistsSubqueries, strings.Count(query, "EXISTS"), "the filter must not grow a subquery per organization: %s", query) - if tc.wantOrgs > 0 { - assert.Contains(t, query, `"projects"."organization_id" IN (`, - "organizations must be matched with a single IN list: %s", query) + + // Every unrestricted organization has to land in that one list. Values are bound, so + // the list is counted by its placeholders rather than read out of the SQL. + assert.Equal(t, tc.wantOrgs, boundOrganizations(query), + "organizations must be matched with a single IN list: %s", query) + + // Nothing else may be bound: one value per organization, and two per granted project. + grantedValues := 0 + for _, projects := range tc.rbac { + grantedValues += len(projects) * 2 } + assert.Len(t, args, tc.wantOrgs+grantedValues, + "the filter binds one value per organization and a pair per grant: %s", query) if tc.wantPairs { assert.Contains(t, query, `("projects"."organization_id", "projects"."id") IN (`, "granted projects must be matched as (org, project) pairs: %s", query) From eb82c12a229a5d28ef153ae389a9ffb61513a62f Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 13:05:36 +0200 Subject: [PATCH 13/15] test(controlplane): exercise a cross-organization grant against the database A grant names an organization as well as a project, and the filter matches the two as a pair for a reason: matching them as independent sets would let a project granted in one organization be reached from another the caller happens to share. That property was asserted only by looking at the SQL the filter renders, which says the pair list is there but not that it does anything. The case now runs against the database, through both the edges request and the reference lookup: a project living in one organization, granted under another, with nothing granted where it actually lives. Dropping the organization from the grant makes it return every edge, so the test fails when the property does. The neighbouring cases already covered no grant, a real grant, and no restriction at all; this was the one missing. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- .../pkg/biz/referrer_integration_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 30fe81717..1aeb85d81 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -702,6 +702,25 @@ func (s *referrerIntegrationTestSuite) TestEdgesAmong() { s.Error(err, "the reference endpoint denies this caller, so the edges endpoint must too") }) + // The grant names an organization as well as a project, and a project is reached through the + // organization it belongs to. Matching the two sets independently would let a project granted + // in one organization be reached from another the caller happens to share. + s.Run("a grant recorded against another organization does not reach the project", func() { + nodes := append([]*biz.ReferrerRef{attestation}, materials...) + crossOrg := map[biz.OrgID][]biz.ProjectID{ + s.org1UUID: {}, // restricted here, nothing granted + s.org2UUID: {s.workflow1.ProjectID}, // granted here, but the project lives in org1 + } + orgs := []uuid.UUID{s.org1UUID, s.org2UUID} + + edges, err := s.Referrer.EdgesAmong(ctx, nodes, orgs, crossOrg) + s.NoError(err) + s.Empty(edges, "the project belongs to an organization where nothing is granted") + + _, _, err = s.Referrer.GetFromRoot(ctx, attestation.Digest, attestation.Kind, orgs, crossOrg, nil) + s.Error(err, "the reference endpoint must refuse it for the same reason") + }) + s.Run("a caller granted the project still gets every edge", func() { nodes := append([]*biz.ReferrerRef{attestation}, materials...) granted := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} From 59fa91e321c4ca928ebb8874f467c541b8af9519 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 16 Sep 2026 13:55:46 +0200 Subject: [PATCH 14/15] fix(controlplane): refuse a missing referrer instead of dereferencing it EdgesAmong reads each node by value to count the distinct ones, so a nil entry panicked. The RPC cannot produce one, but the use case is exported and says nothing about it. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 --- app/controlplane/pkg/biz/referrer.go | 3 +++ app/controlplane/pkg/biz/referrer_integration_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index cd56cb895..2731caeb1 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -256,6 +256,9 @@ func (s *ReferrerUseCase) EdgesAmong(ctx context.Context, nodes []*ReferrerRef, // ones describes no connection, and is a mistake rather than a question. distinct := make(map[ReferrerRef]struct{}, len(nodes)) for _, n := range nodes { + if n == nil { + return nil, NewErrValidationStr("a referrer is missing") + } distinct[*n] = struct{}{} } if len(distinct) < 2 { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 1aeb85d81..9a6e86e06 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -803,6 +803,12 @@ func (s *referrerIntegrationTestSuite) TestEdgesAmong() { s.Empty(edges, "neither exists, so neither contributes an edge") }) + s.Run("a missing referrer is refused rather than dereferenced", func() { + _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, nil}, s.user.ID) + s.Error(err) + s.True(biz.IsErrValidation(err)) + }) + s.Run("the same referrer twice is not two referrers", func() { _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, attestation}, s.user.ID) s.Error(err, "two positions naming one referrer describe no connection") From 957265cf476f4127926de07102cb36900869e70a Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 17 Sep 2026 12:19:46 +0200 Subject: [PATCH 15/15] revert(controlplane): drop the DiscoverEdges endpoint Removes the RPC, its request and response messages, the service handler, the authorization policy entry, the use cases and the repository method, along with the predicates and the generated code that existed only to serve it. The project visibility assertions it carried are about DiscoverPrivate as much as about the endpoint, so they stay as a test of their own: a caller granted a project reaches it, a caller restricted to none does not, and a grant naming one organization does not reach a project living in another. What this branch still does is make the cost of referrer discovery independent of how many organizations the caller belongs to. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 04123fef-eb07-444e-b017-b168c4fa1889 Signed-off-by: Javier Rodriguez --- .../api/controlplane/v1/referrer.pb.go | 325 ++------------- .../api/controlplane/v1/referrer.proto | 88 ---- .../api/controlplane/v1/referrer_grpc.pb.go | 39 -- .../api/controlplane/v1/referrer_http.pb.go | 40 -- .../gen/frontend/controlplane/v1/referrer.ts | 380 ------------------ ...ntrolplane.v1.ReferrerEdge.jsonschema.json | 22 - .../controlplane.v1.ReferrerEdge.schema.json | 22 - ...ontrolplane.v1.ReferrerRef.jsonschema.json | 20 - .../controlplane.v1.ReferrerRef.schema.json | 20 - ...erviceDiscoverEdgesRequest.jsonschema.json | 35 -- ...rerServiceDiscoverEdgesRequest.schema.json | 35 -- ...rviceDiscoverEdgesResponse.jsonschema.json | 17 - ...erServiceDiscoverEdgesResponse.schema.json | 17 - app/controlplane/api/gen/openapi/openapi.yaml | 113 +----- app/controlplane/internal/service/referrer.go | 53 --- .../referrer_edges_integration_test.go | 155 ------- app/controlplane/pkg/authz/authz.go | 1 - app/controlplane/pkg/biz/referrer.go | 69 ---- .../pkg/biz/referrer_integration_test.go | 198 +-------- app/controlplane/pkg/data/referrer.go | 216 ---------- 20 files changed, 51 insertions(+), 1814 deletions(-) delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json delete mode 100644 app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json delete mode 100644 app/controlplane/internal/service/referrer_edges_integration_test.go diff --git a/app/controlplane/api/controlplane/v1/referrer.pb.go b/app/controlplane/api/controlplane/v1/referrer.pb.go index 124314243..160ea580b 100644 --- a/app/controlplane/api/controlplane/v1/referrer.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer.pb.go @@ -40,233 +40,6 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method -type ReferrerServiceDiscoverEdgesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The referrers to find connections between. A digest alone does not identify a referrer, so - // the kind travels with it. - // - // This is a set. A referrer may be listed more than once — a client that collected referrers - // from several attestations will have repeats — and is then reported at the position it first - // appeared in. At least two distinct referrers are required. - Nodes []*ReferrerRef `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` - // Restricts the answer to a project, the same way DiscoverPrivate does - ProjectName string `protobuf:"bytes,2,opt,name=project_name,json=projectName,proto3" json:"project_name,omitempty"` - // Only meaningful alongside project_name: version names are unique within a project - ProjectVersion string `protobuf:"bytes,3,opt,name=project_version,json=projectVersion,proto3" json:"project_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReferrerServiceDiscoverEdgesRequest) Reset() { - *x = ReferrerServiceDiscoverEdgesRequest{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReferrerServiceDiscoverEdgesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReferrerServiceDiscoverEdgesRequest) ProtoMessage() {} - -func (x *ReferrerServiceDiscoverEdgesRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReferrerServiceDiscoverEdgesRequest.ProtoReflect.Descriptor instead. -func (*ReferrerServiceDiscoverEdgesRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{0} -} - -func (x *ReferrerServiceDiscoverEdgesRequest) GetNodes() []*ReferrerRef { - if x != nil { - return x.Nodes - } - return nil -} - -func (x *ReferrerServiceDiscoverEdgesRequest) GetProjectName() string { - if x != nil { - return x.ProjectName - } - return "" -} - -func (x *ReferrerServiceDiscoverEdgesRequest) GetProjectVersion() string { - if x != nil { - return x.ProjectVersion - } - return "" -} - -// ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method -type ReferrerServiceDiscoverEdgesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Each connection once, regardless of the direction it is stored in, and referred to by the - // first position its referrers were listed under in the request - Edges []*ReferrerEdge `protobuf:"bytes,1,rep,name=edges,proto3" json:"edges,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReferrerServiceDiscoverEdgesResponse) Reset() { - *x = ReferrerServiceDiscoverEdgesResponse{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReferrerServiceDiscoverEdgesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReferrerServiceDiscoverEdgesResponse) ProtoMessage() {} - -func (x *ReferrerServiceDiscoverEdgesResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReferrerServiceDiscoverEdgesResponse.ProtoReflect.Descriptor instead. -func (*ReferrerServiceDiscoverEdgesResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{1} -} - -func (x *ReferrerServiceDiscoverEdgesResponse) GetEdges() []*ReferrerEdge { - if x != nil { - return x.Edges - } - return nil -} - -// ReferrerRef identifies a referrer -type ReferrerRef struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead - Digest string `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` - // Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReferrerRef) Reset() { - *x = ReferrerRef{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReferrerRef) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReferrerRef) ProtoMessage() {} - -func (x *ReferrerRef) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReferrerRef.ProtoReflect.Descriptor instead. -func (*ReferrerRef) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{2} -} - -func (x *ReferrerRef) GetDigest() string { - if x != nil { - return x.Digest - } - return "" -} - -func (x *ReferrerRef) GetKind() string { - if x != nil { - return x.Kind - } - return "" -} - -// ReferrerEdge is a connection between two of the requested referrers, given as indexes into the -// request's nodes so the digests do not travel back -type ReferrerEdge struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Index of the lower end of the connection, always smaller than to - From uint32 `protobuf:"varint,1,opt,name=from,proto3" json:"from,omitempty"` - // Index of the higher end of the connection - To uint32 `protobuf:"varint,2,opt,name=to,proto3" json:"to,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ReferrerEdge) Reset() { - *x = ReferrerEdge{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ReferrerEdge) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ReferrerEdge) ProtoMessage() {} - -func (x *ReferrerEdge) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ReferrerEdge.ProtoReflect.Descriptor instead. -func (*ReferrerEdge) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{3} -} - -func (x *ReferrerEdge) GetFrom() uint32 { - if x != nil { - return x.From - } - return 0 -} - -func (x *ReferrerEdge) GetTo() uint32 { - if x != nil { - return x.To - } - return 0 -} - // ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method type ReferrerServiceDiscoverPrivateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -289,7 +62,7 @@ type ReferrerServiceDiscoverPrivateRequest struct { func (x *ReferrerServiceDiscoverPrivateRequest) Reset() { *x = ReferrerServiceDiscoverPrivateRequest{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[4] + mi := &file_controlplane_v1_referrer_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -301,7 +74,7 @@ func (x *ReferrerServiceDiscoverPrivateRequest) String() string { func (*ReferrerServiceDiscoverPrivateRequest) ProtoMessage() {} func (x *ReferrerServiceDiscoverPrivateRequest) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[4] + mi := &file_controlplane_v1_referrer_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -314,7 +87,7 @@ func (x *ReferrerServiceDiscoverPrivateRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ReferrerServiceDiscoverPrivateRequest.ProtoReflect.Descriptor instead. func (*ReferrerServiceDiscoverPrivateRequest) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{4} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{0} } func (x *ReferrerServiceDiscoverPrivateRequest) GetDigest() string { @@ -365,7 +138,7 @@ type ReferrerServiceDiscoverPrivateResponse struct { func (x *ReferrerServiceDiscoverPrivateResponse) Reset() { *x = ReferrerServiceDiscoverPrivateResponse{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[5] + mi := &file_controlplane_v1_referrer_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -377,7 +150,7 @@ func (x *ReferrerServiceDiscoverPrivateResponse) String() string { func (*ReferrerServiceDiscoverPrivateResponse) ProtoMessage() {} func (x *ReferrerServiceDiscoverPrivateResponse) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[5] + mi := &file_controlplane_v1_referrer_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -390,7 +163,7 @@ func (x *ReferrerServiceDiscoverPrivateResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ReferrerServiceDiscoverPrivateResponse.ProtoReflect.Descriptor instead. func (*ReferrerServiceDiscoverPrivateResponse) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{5} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{1} } func (x *ReferrerServiceDiscoverPrivateResponse) GetResult() *ReferrerItem { @@ -430,7 +203,7 @@ type ReferrerItem struct { func (x *ReferrerItem) Reset() { *x = ReferrerItem{} - mi := &file_controlplane_v1_referrer_proto_msgTypes[6] + mi := &file_controlplane_v1_referrer_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -442,7 +215,7 @@ func (x *ReferrerItem) String() string { func (*ReferrerItem) ProtoMessage() {} func (x *ReferrerItem) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_v1_referrer_proto_msgTypes[6] + mi := &file_controlplane_v1_referrer_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -455,7 +228,7 @@ func (x *ReferrerItem) ProtoReflect() protoreflect.Message { // Deprecated: Use ReferrerItem.ProtoReflect.Descriptor instead. func (*ReferrerItem) Descriptor() ([]byte, []int) { - return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{6} + return file_controlplane_v1_referrer_proto_rawDescGZIP(), []int{2} } func (x *ReferrerItem) GetDigest() string { @@ -511,24 +284,7 @@ var File_controlplane_v1_referrer_proto protoreflect.FileDescriptor const file_controlplane_v1_referrer_proto_rawDesc = "" + "\n" + - "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xc6\x03\n" + - "#ReferrerServiceDiscoverEdgesRequest\x12?\n" + - "\x05nodes\x18\x01 \x03(\v2\x1c.controlplane.v1.ReferrerRefB\v\xbaH\b\x92\x01\x05\b\x02\x10\xe8\aR\x05nodes\x12!\n" + - "\fproject_name\x18\x02 \x01(\tR\vprojectName\x12'\n" + - "\x0fproject_version\x18\x03 \x01(\tR\x0eprojectVersion:\x91\x02\x92Aa\n" + - "_*#ReferrerServiceDiscoverEdgesRequest28Request to discover the edges between a set of referrers\xbaH\xa9\x01\x1a\xa6\x01\n" + - "4discover_edges_project_version_requires_project_name\x124project_name must be set when project_version is set\x1a8!(this.project_version != '' && this.project_name == '')\"\xaf\x01\n" + - "$ReferrerServiceDiscoverEdgesResponse\x123\n" + - "\x05edges\x18\x01 \x03(\v2\x1d.controlplane.v1.ReferrerEdgeR\x05edges:R\x92AO\n" + - "M*$ReferrerServiceDiscoverEdgesResponse2%Response for the DiscoverEdges method\"\x8d\x01\n" + - "\vReferrerRef\x12\x1f\n" + - "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x1b\n" + - "\x04kind\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04kind:@\x92A=\n" + - ";*\vReferrerRef2,A reference to a referrer by digest and kind\"\x88\x01\n" + - "\fReferrerEdge\x12\x12\n" + - "\x04from\x18\x01 \x01(\rR\x04from\x12\x0e\n" + - "\x02to\x18\x02 \x01(\rR\x02to:T\x92AQ\n" + - "O*\fReferrerEdge2?A connection between two referrers, as indexes into the request\"\xf0\x03\n" + + "\x1econtrolplane/v1/referrer.proto\x12\x0fcontrolplane.v1\x1a\x1bbuf/validate/validate.proto\x1a controlplane/v1/pagination.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\xf0\x03\n" + "%ReferrerServiceDiscoverPrivateRequest\x12\x1f\n" + "\x06digest\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06digest\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12H\n" + @@ -562,10 +318,9 @@ const file_controlplane_v1_referrer_proto_rawDesc = "" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01:B\x92A?\n" + - "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\xa5\x06\n" + + "=*\fReferrerItem2-It represents a referrer object in the systemJ\x04\b\x06\x10\aR\x06public2\x90\x03\n" + "\x0fReferrerService\x12\xa9\x02\n" + - "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\xa4\x01\x92A\x86\x01\x12\x19Discover private referrer\x1aWReturns the referrer item for a given digest in the organizations of the logged-in user:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x12\x92\x03\n" + - "\rDiscoverEdges\x124.controlplane.v1.ReferrerServiceDiscoverEdgesRequest\x1a5.controlplane.v1.ReferrerServiceDiscoverEdgesResponse\"\x93\x02\x92A\xf5\x01\x12*Discover the edges between known referrers\x1a\xb4\x01Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges.:\x10application/json\x82\xd3\xe4\x93\x02\x14:\x01*\"\x0f/discover/edges\x1aQ\x92AN\n" + + "\x0fDiscoverPrivate\x126.controlplane.v1.ReferrerServiceDiscoverPrivateRequest\x1a7.controlplane.v1.ReferrerServiceDiscoverPrivateResponse\"\xa4\x01\x92A\x86\x01\x12\x19Discover private referrer\x1aWReturns the referrer item for a given digest in the organizations of the logged-in user:\x10application/json\x82\xd3\xe4\x93\x02\x14\x12\x12/discover/{digest}\x1aQ\x92AN\n" + "\x0fReferrerService\x12;Referrer service for discovering referred content by digestBLZJgithub.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1;v1b\x06proto3" var ( @@ -580,40 +335,32 @@ func file_controlplane_v1_referrer_proto_rawDescGZIP() []byte { return file_controlplane_v1_referrer_proto_rawDescData } -var file_controlplane_v1_referrer_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_controlplane_v1_referrer_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_controlplane_v1_referrer_proto_goTypes = []any{ - (*ReferrerServiceDiscoverEdgesRequest)(nil), // 0: controlplane.v1.ReferrerServiceDiscoverEdgesRequest - (*ReferrerServiceDiscoverEdgesResponse)(nil), // 1: controlplane.v1.ReferrerServiceDiscoverEdgesResponse - (*ReferrerRef)(nil), // 2: controlplane.v1.ReferrerRef - (*ReferrerEdge)(nil), // 3: controlplane.v1.ReferrerEdge - (*ReferrerServiceDiscoverPrivateRequest)(nil), // 4: controlplane.v1.ReferrerServiceDiscoverPrivateRequest - (*ReferrerServiceDiscoverPrivateResponse)(nil), // 5: controlplane.v1.ReferrerServiceDiscoverPrivateResponse - (*ReferrerItem)(nil), // 6: controlplane.v1.ReferrerItem - nil, // 7: controlplane.v1.ReferrerItem.MetadataEntry - nil, // 8: controlplane.v1.ReferrerItem.AnnotationsEntry - (*CursorPaginationRequest)(nil), // 9: controlplane.v1.CursorPaginationRequest - (*CursorPaginationResponse)(nil), // 10: controlplane.v1.CursorPaginationResponse - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*ReferrerServiceDiscoverPrivateRequest)(nil), // 0: controlplane.v1.ReferrerServiceDiscoverPrivateRequest + (*ReferrerServiceDiscoverPrivateResponse)(nil), // 1: controlplane.v1.ReferrerServiceDiscoverPrivateResponse + (*ReferrerItem)(nil), // 2: controlplane.v1.ReferrerItem + nil, // 3: controlplane.v1.ReferrerItem.MetadataEntry + nil, // 4: controlplane.v1.ReferrerItem.AnnotationsEntry + (*CursorPaginationRequest)(nil), // 5: controlplane.v1.CursorPaginationRequest + (*CursorPaginationResponse)(nil), // 6: controlplane.v1.CursorPaginationResponse + (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp } var file_controlplane_v1_referrer_proto_depIdxs = []int32{ - 2, // 0: controlplane.v1.ReferrerServiceDiscoverEdgesRequest.nodes:type_name -> controlplane.v1.ReferrerRef - 3, // 1: controlplane.v1.ReferrerServiceDiscoverEdgesResponse.edges:type_name -> controlplane.v1.ReferrerEdge - 9, // 2: controlplane.v1.ReferrerServiceDiscoverPrivateRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest - 6, // 3: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem - 10, // 4: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse - 6, // 5: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem - 11, // 6: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp - 7, // 7: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry - 8, // 8: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry - 4, // 9: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest - 0, // 10: controlplane.v1.ReferrerService.DiscoverEdges:input_type -> controlplane.v1.ReferrerServiceDiscoverEdgesRequest - 5, // 11: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse - 1, // 12: controlplane.v1.ReferrerService.DiscoverEdges:output_type -> controlplane.v1.ReferrerServiceDiscoverEdgesResponse - 11, // [11:13] is the sub-list for method output_type - 9, // [9:11] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 5, // 0: controlplane.v1.ReferrerServiceDiscoverPrivateRequest.pagination:type_name -> controlplane.v1.CursorPaginationRequest + 2, // 1: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.result:type_name -> controlplane.v1.ReferrerItem + 6, // 2: controlplane.v1.ReferrerServiceDiscoverPrivateResponse.pagination:type_name -> controlplane.v1.CursorPaginationResponse + 2, // 3: controlplane.v1.ReferrerItem.references:type_name -> controlplane.v1.ReferrerItem + 7, // 4: controlplane.v1.ReferrerItem.created_at:type_name -> google.protobuf.Timestamp + 3, // 5: controlplane.v1.ReferrerItem.metadata:type_name -> controlplane.v1.ReferrerItem.MetadataEntry + 4, // 6: controlplane.v1.ReferrerItem.annotations:type_name -> controlplane.v1.ReferrerItem.AnnotationsEntry + 0, // 7: controlplane.v1.ReferrerService.DiscoverPrivate:input_type -> controlplane.v1.ReferrerServiceDiscoverPrivateRequest + 1, // 8: controlplane.v1.ReferrerService.DiscoverPrivate:output_type -> controlplane.v1.ReferrerServiceDiscoverPrivateResponse + 8, // [8:9] is the sub-list for method output_type + 7, // [7:8] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_controlplane_v1_referrer_proto_init() } @@ -628,7 +375,7 @@ func file_controlplane_v1_referrer_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_referrer_proto_rawDesc), len(file_controlplane_v1_referrer_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/referrer.proto b/app/controlplane/api/controlplane/v1/referrer.proto index 3617d34aa..ce5be09f9 100644 --- a/app/controlplane/api/controlplane/v1/referrer.proto +++ b/app/controlplane/api/controlplane/v1/referrer.proto @@ -35,100 +35,12 @@ service ReferrerService { produces: ["application/json"] }; } - // DiscoverEdges returns the connections between a set of referrers the caller already holds - rpc DiscoverEdges(ReferrerServiceDiscoverEdgesRequest) returns (ReferrerServiceDiscoverEdgesResponse) { - option (google.api.http) = { - post: "/discover/edges" - body: "*" - }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Discover the edges between known referrers" - description: "Returns every connection between the given referrers that is visible to the logged-in user, as pairs of indexes into the request. Referrers the user cannot see contribute no edges." - produces: ["application/json"] - }; - } option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_tag) = { name: "ReferrerService" description: "Referrer service for discovering referred content by digest" }; } -// ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method -message ReferrerServiceDiscoverEdgesRequest { - // The referrers to find connections between. A digest alone does not identify a referrer, so - // the kind travels with it. - // - // This is a set. A referrer may be listed more than once — a client that collected referrers - // from several attestations will have repeats — and is then reported at the position it first - // appeared in. At least two distinct referrers are required. - repeated ReferrerRef nodes = 1 [(buf.validate.field).repeated = { - min_items: 2 - max_items: 1000 - }]; - // Restricts the answer to a project, the same way DiscoverPrivate does - string project_name = 2; - // Only meaningful alongside project_name: version names are unique within a project - string project_version = 3; - - option (buf.validate.message).cel = { - id: "discover_edges_project_version_requires_project_name" - expression: "!(this.project_version != '' && this.project_name == '')" - message: "project_name must be set when project_version is set" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { - json_schema: { - title: "ReferrerServiceDiscoverEdgesRequest" - description: "Request to discover the edges between a set of referrers" - } - }; -} - -// ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method -message ReferrerServiceDiscoverEdgesResponse { - // Each connection once, regardless of the direction it is stored in, and referred to by the - // first position its referrers were listed under in the request - repeated ReferrerEdge edges = 1; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { - json_schema: { - title: "ReferrerServiceDiscoverEdgesResponse" - description: "Response for the DiscoverEdges method" - } - }; -} - -// ReferrerRef identifies a referrer -message ReferrerRef { - // Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead - string digest = 1 [(buf.validate.field).string = {min_len: 1}]; - // Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... - string kind = 2 [(buf.validate.field).string = {min_len: 1}]; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { - json_schema: { - title: "ReferrerRef" - description: "A reference to a referrer by digest and kind" - } - }; -} - -// ReferrerEdge is a connection between two of the requested referrers, given as indexes into the -// request's nodes so the digests do not travel back -message ReferrerEdge { - // Index of the lower end of the connection, always smaller than to - uint32 from = 1; - // Index of the higher end of the connection - uint32 to = 2; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { - json_schema: { - title: "ReferrerEdge" - description: "A connection between two referrers, as indexes into the request" - } - }; -} - // ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method message ReferrerServiceDiscoverPrivateRequest { // Digest is the unique identifier of the referrer to discover diff --git a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go index 00dc2e343..62856be96 100644 --- a/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_grpc.pb.go @@ -35,7 +35,6 @@ const _ = grpc.SupportPackageIsVersion7 const ( ReferrerService_DiscoverPrivate_FullMethodName = "/controlplane.v1.ReferrerService/DiscoverPrivate" - ReferrerService_DiscoverEdges_FullMethodName = "/controlplane.v1.ReferrerService/DiscoverEdges" ) // ReferrerServiceClient is the client API for ReferrerService service. @@ -44,8 +43,6 @@ const ( type ReferrerServiceClient interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) - // DiscoverEdges returns the connections between a set of referrers the caller already holds - DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) } type referrerServiceClient struct { @@ -65,23 +62,12 @@ func (c *referrerServiceClient) DiscoverPrivate(ctx context.Context, in *Referre return out, nil } -func (c *referrerServiceClient) DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...grpc.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) { - out := new(ReferrerServiceDiscoverEdgesResponse) - err := c.cc.Invoke(ctx, ReferrerService_DiscoverEdges_FullMethodName, in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - // ReferrerServiceServer is the server API for ReferrerService service. // All implementations must embed UnimplementedReferrerServiceServer // for forward compatibility type ReferrerServiceServer interface { // DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) - // DiscoverEdges returns the connections between a set of referrers the caller already holds - DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) mustEmbedUnimplementedReferrerServiceServer() } @@ -92,9 +78,6 @@ type UnimplementedReferrerServiceServer struct { func (UnimplementedReferrerServiceServer) DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method DiscoverPrivate not implemented") } -func (UnimplementedReferrerServiceServer) DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DiscoverEdges not implemented") -} func (UnimplementedReferrerServiceServer) mustEmbedUnimplementedReferrerServiceServer() {} // UnsafeReferrerServiceServer may be embedded to opt out of forward compatibility for this service. @@ -126,24 +109,6 @@ func _ReferrerService_DiscoverPrivate_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } -func _ReferrerService_DiscoverEdges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReferrerServiceDiscoverEdgesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ReferrerServiceServer).DiscoverEdges(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ReferrerService_DiscoverEdges_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ReferrerServiceServer).DiscoverEdges(ctx, req.(*ReferrerServiceDiscoverEdgesRequest)) - } - return interceptor(ctx, in, info, handler) -} - // ReferrerService_ServiceDesc is the grpc.ServiceDesc for ReferrerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -155,10 +120,6 @@ var ReferrerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DiscoverPrivate", Handler: _ReferrerService_DiscoverPrivate_Handler, }, - { - MethodName: "DiscoverEdges", - Handler: _ReferrerService_DiscoverEdges_Handler, - }, }, Streams: []grpc.StreamDesc{}, Metadata: "controlplane/v1/referrer.proto", diff --git a/app/controlplane/api/controlplane/v1/referrer_http.pb.go b/app/controlplane/api/controlplane/v1/referrer_http.pb.go index 6a1ff86f7..24f71246a 100644 --- a/app/controlplane/api/controlplane/v1/referrer_http.pb.go +++ b/app/controlplane/api/controlplane/v1/referrer_http.pb.go @@ -19,12 +19,9 @@ var _ = binding.EncodeURL const _ = http.SupportPackageIsVersion1 -const OperationReferrerServiceDiscoverEdges = "/controlplane.v1.ReferrerService/DiscoverEdges" const OperationReferrerServiceDiscoverPrivate = "/controlplane.v1.ReferrerService/DiscoverPrivate" type ReferrerServiceHTTPServer interface { - // DiscoverEdges DiscoverEdges returns the connections between a set of referrers the caller already holds - DiscoverEdges(context.Context, *ReferrerServiceDiscoverEdgesRequest) (*ReferrerServiceDiscoverEdgesResponse, error) // DiscoverPrivate DiscoverPrivate returns the referrer item for a given digest in the organizations of the logged-in user DiscoverPrivate(context.Context, *ReferrerServiceDiscoverPrivateRequest) (*ReferrerServiceDiscoverPrivateResponse, error) } @@ -32,7 +29,6 @@ type ReferrerServiceHTTPServer interface { func RegisterReferrerServiceHTTPServer(s *http.Server, srv ReferrerServiceHTTPServer) { r := s.Route("/") r.GET("/discover/{digest}", _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv)) - r.POST("/discover/edges", _ReferrerService_DiscoverEdges0_HTTP_Handler(srv)) } func _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv ReferrerServiceHTTPServer) func(ctx http.Context) error { @@ -57,30 +53,7 @@ func _ReferrerService_DiscoverPrivate0_HTTP_Handler(srv ReferrerServiceHTTPServe } } -func _ReferrerService_DiscoverEdges0_HTTP_Handler(srv ReferrerServiceHTTPServer) func(ctx http.Context) error { - return func(ctx http.Context) error { - var in ReferrerServiceDiscoverEdgesRequest - if err := ctx.Bind(&in); err != nil { - return err - } - if err := ctx.BindQuery(&in); err != nil { - return err - } - http.SetOperation(ctx, OperationReferrerServiceDiscoverEdges) - h := ctx.Middleware(func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.DiscoverEdges(ctx, req.(*ReferrerServiceDiscoverEdgesRequest)) - }) - out, err := h(ctx, &in) - if err != nil { - return err - } - reply := out.(*ReferrerServiceDiscoverEdgesResponse) - return ctx.Result(200, reply) - } -} - type ReferrerServiceHTTPClient interface { - DiscoverEdges(ctx context.Context, req *ReferrerServiceDiscoverEdgesRequest, opts ...http.CallOption) (rsp *ReferrerServiceDiscoverEdgesResponse, err error) DiscoverPrivate(ctx context.Context, req *ReferrerServiceDiscoverPrivateRequest, opts ...http.CallOption) (rsp *ReferrerServiceDiscoverPrivateResponse, err error) } @@ -92,19 +65,6 @@ func NewReferrerServiceHTTPClient(client *http.Client) ReferrerServiceHTTPClient return &ReferrerServiceHTTPClientImpl{client} } -func (c *ReferrerServiceHTTPClientImpl) DiscoverEdges(ctx context.Context, in *ReferrerServiceDiscoverEdgesRequest, opts ...http.CallOption) (*ReferrerServiceDiscoverEdgesResponse, error) { - var out ReferrerServiceDiscoverEdgesResponse - pattern := "/discover/edges" - path := binding.EncodeURL(pattern, in, false) - opts = append(opts, http.Operation(OperationReferrerServiceDiscoverEdges)) - opts = append(opts, http.PathTemplate(pattern)) - err := c.cc.Invoke(ctx, "POST", path, in, &out, opts...) - if err != nil { - return nil, err - } - return &out, err -} - func (c *ReferrerServiceHTTPClientImpl) DiscoverPrivate(ctx context.Context, in *ReferrerServiceDiscoverPrivateRequest, opts ...http.CallOption) (*ReferrerServiceDiscoverPrivateResponse, error) { var out ReferrerServiceDiscoverPrivateResponse pattern := "/discover/{digest}" diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts index f159dbdbd..270c7e357 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/referrer.ts @@ -7,51 +7,6 @@ import { CursorPaginationRequest, CursorPaginationResponse } from "./pagination" export const protobufPackage = "controlplane.v1"; -/** ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method */ -export interface ReferrerServiceDiscoverEdgesRequest { - /** - * The referrers to find connections between. A digest alone does not identify a referrer, so - * the kind travels with it. - * - * This is a set. A referrer may be listed more than once — a client that collected referrers - * from several attestations will have repeats — and is then reported at the position it first - * appeared in. At least two distinct referrers are required. - */ - nodes: ReferrerRef[]; - /** Restricts the answer to a project, the same way DiscoverPrivate does */ - projectName: string; - /** Only meaningful alongside project_name: version names are unique within a project */ - projectVersion: string; -} - -/** ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method */ -export interface ReferrerServiceDiscoverEdgesResponse { - /** - * Each connection once, regardless of the direction it is stored in, and referred to by the - * first position its referrers were listed under in the request - */ - edges: ReferrerEdge[]; -} - -/** ReferrerRef identifies a referrer */ -export interface ReferrerRef { - /** Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead */ - digest: string; - /** Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... */ - kind: string; -} - -/** - * ReferrerEdge is a connection between two of the requested referrers, given as indexes into the - * request's nodes so the digests do not travel back - */ -export interface ReferrerEdge { - /** Index of the lower end of the connection, always smaller than to */ - from: number; - /** Index of the higher end of the connection */ - to: number; -} - /** ReferrerServiceDiscoverPrivateRequest is the request for the DiscoverPrivate method */ export interface ReferrerServiceDiscoverPrivateRequest { /** Digest is the unique identifier of the referrer to discover */ @@ -111,301 +66,6 @@ export interface ReferrerItem_AnnotationsEntry { value: string; } -function createBaseReferrerServiceDiscoverEdgesRequest(): ReferrerServiceDiscoverEdgesRequest { - return { nodes: [], projectName: "", projectVersion: "" }; -} - -export const ReferrerServiceDiscoverEdgesRequest = { - encode(message: ReferrerServiceDiscoverEdgesRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - for (const v of message.nodes) { - ReferrerRef.encode(v!, writer.uint32(10).fork()).ldelim(); - } - if (message.projectName !== "") { - writer.uint32(18).string(message.projectName); - } - if (message.projectVersion !== "") { - writer.uint32(26).string(message.projectVersion); - } - return writer; - }, - - decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerServiceDiscoverEdgesRequest { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); - let end = length === undefined ? reader.len : reader.pos + length; - const message = createBaseReferrerServiceDiscoverEdgesRequest(); - while (reader.pos < end) { - const tag = reader.uint32(); - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break; - } - - message.nodes.push(ReferrerRef.decode(reader, reader.uint32())); - continue; - case 2: - if (tag !== 18) { - break; - } - - message.projectName = reader.string(); - continue; - case 3: - if (tag !== 26) { - break; - } - - message.projectVersion = reader.string(); - continue; - } - if ((tag & 7) === 4 || tag === 0) { - break; - } - reader.skipType(tag & 7); - } - return message; - }, - - fromJSON(object: any): ReferrerServiceDiscoverEdgesRequest { - return { - nodes: Array.isArray(object?.nodes) ? object.nodes.map((e: any) => ReferrerRef.fromJSON(e)) : [], - projectName: isSet(object.projectName) ? String(object.projectName) : "", - projectVersion: isSet(object.projectVersion) ? String(object.projectVersion) : "", - }; - }, - - toJSON(message: ReferrerServiceDiscoverEdgesRequest): unknown { - const obj: any = {}; - if (message.nodes) { - obj.nodes = message.nodes.map((e) => e ? ReferrerRef.toJSON(e) : undefined); - } else { - obj.nodes = []; - } - message.projectName !== undefined && (obj.projectName = message.projectName); - message.projectVersion !== undefined && (obj.projectVersion = message.projectVersion); - return obj; - }, - - create, I>>( - base?: I, - ): ReferrerServiceDiscoverEdgesRequest { - return ReferrerServiceDiscoverEdgesRequest.fromPartial(base ?? {}); - }, - - fromPartial, I>>( - object: I, - ): ReferrerServiceDiscoverEdgesRequest { - const message = createBaseReferrerServiceDiscoverEdgesRequest(); - message.nodes = object.nodes?.map((e) => ReferrerRef.fromPartial(e)) || []; - message.projectName = object.projectName ?? ""; - message.projectVersion = object.projectVersion ?? ""; - return message; - }, -}; - -function createBaseReferrerServiceDiscoverEdgesResponse(): ReferrerServiceDiscoverEdgesResponse { - return { edges: [] }; -} - -export const ReferrerServiceDiscoverEdgesResponse = { - encode(message: ReferrerServiceDiscoverEdgesResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - for (const v of message.edges) { - ReferrerEdge.encode(v!, writer.uint32(10).fork()).ldelim(); - } - return writer; - }, - - decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerServiceDiscoverEdgesResponse { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); - let end = length === undefined ? reader.len : reader.pos + length; - const message = createBaseReferrerServiceDiscoverEdgesResponse(); - while (reader.pos < end) { - const tag = reader.uint32(); - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break; - } - - message.edges.push(ReferrerEdge.decode(reader, reader.uint32())); - continue; - } - if ((tag & 7) === 4 || tag === 0) { - break; - } - reader.skipType(tag & 7); - } - return message; - }, - - fromJSON(object: any): ReferrerServiceDiscoverEdgesResponse { - return { edges: Array.isArray(object?.edges) ? object.edges.map((e: any) => ReferrerEdge.fromJSON(e)) : [] }; - }, - - toJSON(message: ReferrerServiceDiscoverEdgesResponse): unknown { - const obj: any = {}; - if (message.edges) { - obj.edges = message.edges.map((e) => e ? ReferrerEdge.toJSON(e) : undefined); - } else { - obj.edges = []; - } - return obj; - }, - - create, I>>( - base?: I, - ): ReferrerServiceDiscoverEdgesResponse { - return ReferrerServiceDiscoverEdgesResponse.fromPartial(base ?? {}); - }, - - fromPartial, I>>( - object: I, - ): ReferrerServiceDiscoverEdgesResponse { - const message = createBaseReferrerServiceDiscoverEdgesResponse(); - message.edges = object.edges?.map((e) => ReferrerEdge.fromPartial(e)) || []; - return message; - }, -}; - -function createBaseReferrerRef(): ReferrerRef { - return { digest: "", kind: "" }; -} - -export const ReferrerRef = { - encode(message: ReferrerRef, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - if (message.digest !== "") { - writer.uint32(10).string(message.digest); - } - if (message.kind !== "") { - writer.uint32(18).string(message.kind); - } - return writer; - }, - - decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerRef { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); - let end = length === undefined ? reader.len : reader.pos + length; - const message = createBaseReferrerRef(); - while (reader.pos < end) { - const tag = reader.uint32(); - switch (tag >>> 3) { - case 1: - if (tag !== 10) { - break; - } - - message.digest = reader.string(); - continue; - case 2: - if (tag !== 18) { - break; - } - - message.kind = reader.string(); - continue; - } - if ((tag & 7) === 4 || tag === 0) { - break; - } - reader.skipType(tag & 7); - } - return message; - }, - - fromJSON(object: any): ReferrerRef { - return { - digest: isSet(object.digest) ? String(object.digest) : "", - kind: isSet(object.kind) ? String(object.kind) : "", - }; - }, - - toJSON(message: ReferrerRef): unknown { - const obj: any = {}; - message.digest !== undefined && (obj.digest = message.digest); - message.kind !== undefined && (obj.kind = message.kind); - return obj; - }, - - create, I>>(base?: I): ReferrerRef { - return ReferrerRef.fromPartial(base ?? {}); - }, - - fromPartial, I>>(object: I): ReferrerRef { - const message = createBaseReferrerRef(); - message.digest = object.digest ?? ""; - message.kind = object.kind ?? ""; - return message; - }, -}; - -function createBaseReferrerEdge(): ReferrerEdge { - return { from: 0, to: 0 }; -} - -export const ReferrerEdge = { - encode(message: ReferrerEdge, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { - if (message.from !== 0) { - writer.uint32(8).uint32(message.from); - } - if (message.to !== 0) { - writer.uint32(16).uint32(message.to); - } - return writer; - }, - - decode(input: _m0.Reader | Uint8Array, length?: number): ReferrerEdge { - const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); - let end = length === undefined ? reader.len : reader.pos + length; - const message = createBaseReferrerEdge(); - while (reader.pos < end) { - const tag = reader.uint32(); - switch (tag >>> 3) { - case 1: - if (tag !== 8) { - break; - } - - message.from = reader.uint32(); - continue; - case 2: - if (tag !== 16) { - break; - } - - message.to = reader.uint32(); - continue; - } - if ((tag & 7) === 4 || tag === 0) { - break; - } - reader.skipType(tag & 7); - } - return message; - }, - - fromJSON(object: any): ReferrerEdge { - return { from: isSet(object.from) ? Number(object.from) : 0, to: isSet(object.to) ? Number(object.to) : 0 }; - }, - - toJSON(message: ReferrerEdge): unknown { - const obj: any = {}; - message.from !== undefined && (obj.from = Math.round(message.from)); - message.to !== undefined && (obj.to = Math.round(message.to)); - return obj; - }, - - create, I>>(base?: I): ReferrerEdge { - return ReferrerEdge.fromPartial(base ?? {}); - }, - - fromPartial, I>>(object: I): ReferrerEdge { - const message = createBaseReferrerEdge(); - message.from = object.from ?? 0; - message.to = object.to ?? 0; - return message; - }, -}; - function createBaseReferrerServiceDiscoverPrivateRequest(): ReferrerServiceDiscoverPrivateRequest { return { digest: "", kind: "", pagination: undefined, projectName: "", projectVersion: "" }; } @@ -934,11 +594,6 @@ export interface ReferrerService { request: DeepPartial, metadata?: grpc.Metadata, ): Promise; - /** DiscoverEdges returns the connections between a set of referrers the caller already holds */ - DiscoverEdges( - request: DeepPartial, - metadata?: grpc.Metadata, - ): Promise; } export class ReferrerServiceClientImpl implements ReferrerService { @@ -947,7 +602,6 @@ export class ReferrerServiceClientImpl implements ReferrerService { constructor(rpc: Rpc) { this.rpc = rpc; this.DiscoverPrivate = this.DiscoverPrivate.bind(this); - this.DiscoverEdges = this.DiscoverEdges.bind(this); } DiscoverPrivate( @@ -960,17 +614,6 @@ export class ReferrerServiceClientImpl implements ReferrerService { metadata, ); } - - DiscoverEdges( - request: DeepPartial, - metadata?: grpc.Metadata, - ): Promise { - return this.rpc.unary( - ReferrerServiceDiscoverEdgesDesc, - ReferrerServiceDiscoverEdgesRequest.fromPartial(request), - metadata, - ); - } } export const ReferrerServiceDesc = { serviceName: "controlplane.v1.ReferrerService" }; @@ -998,29 +641,6 @@ export const ReferrerServiceDiscoverPrivateDesc: UnaryMethodDefinitionish = { } as any, }; -export const ReferrerServiceDiscoverEdgesDesc: UnaryMethodDefinitionish = { - methodName: "DiscoverEdges", - service: ReferrerServiceDesc, - requestStream: false, - responseStream: false, - requestType: { - serializeBinary() { - return ReferrerServiceDiscoverEdgesRequest.encode(this).finish(); - }, - } as any, - responseType: { - deserializeBinary(data: Uint8Array) { - const value = ReferrerServiceDiscoverEdgesResponse.decode(data); - return { - ...value, - toObject() { - return value; - }, - }; - }, - } as any, -}; - interface UnaryMethodDefinitionishR extends grpc.UnaryMethodDefinition { requestStream: any; responseStream: any; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json deleted file mode 100644 index 621ffbc19..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.jsonschema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerEdge.jsonschema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerEdge is a connection between two of the requested referrers, given as indexes into the\n request's nodes so the digests do not travel back", - "properties": { - "from": { - "description": "Index of the lower end of the connection, always smaller than to", - "maximum": 4294967295, - "minimum": 0, - "type": "integer" - }, - "to": { - "description": "Index of the higher end of the connection", - "maximum": 4294967295, - "minimum": 0, - "type": "integer" - } - }, - "title": "Referrer Edge", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json deleted file mode 100644 index c930d7463..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerEdge.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerEdge.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerEdge is a connection between two of the requested referrers, given as indexes into the\n request's nodes so the digests do not travel back", - "properties": { - "from": { - "description": "Index of the lower end of the connection, always smaller than to", - "maximum": 4294967295, - "minimum": 0, - "type": "integer" - }, - "to": { - "description": "Index of the higher end of the connection", - "maximum": 4294967295, - "minimum": 0, - "type": "integer" - } - }, - "title": "Referrer Edge", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json deleted file mode 100644 index 7cd9c4a2c..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.jsonschema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerRef.jsonschema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerRef identifies a referrer", - "properties": { - "digest": { - "description": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead", - "minLength": 1, - "type": "string" - }, - "kind": { - "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ...", - "minLength": 1, - "type": "string" - } - }, - "title": "Referrer Ref", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json deleted file mode 100644 index cfe3200f2..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerRef.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerRef.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerRef identifies a referrer", - "properties": { - "digest": { - "description": "Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead", - "minLength": 1, - "type": "string" - }, - "kind": { - "description": "Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ...", - "minLength": 1, - "type": "string" - } - }, - "title": "Referrer Ref", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json deleted file mode 100644 index 000850fa4..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesRequest.jsonschema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method", - "patternProperties": { - "^(project_name)$": { - "description": "Restricts the answer to a project, the same way DiscoverPrivate does", - "type": "string" - }, - "^(project_version)$": { - "description": "Only meaningful alongside project_name: version names are unique within a project", - "type": "string" - } - }, - "properties": { - "nodes": { - "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.\n\n This is a set. A referrer may be listed more than once — a client that collected referrers\n from several attestations will have repeats — and is then reported at the position it first\n appeared in. At least two distinct referrers are required.", - "items": { - "$ref": "controlplane.v1.ReferrerRef.jsonschema.json" - }, - "type": "array" - }, - "projectName": { - "description": "Restricts the answer to a project, the same way DiscoverPrivate does", - "type": "string" - }, - "projectVersion": { - "description": "Only meaningful alongside project_name: version names are unique within a project", - "type": "string" - } - }, - "title": "Referrer Service Discover Edges Request", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json deleted file mode 100644 index 420cf2504..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesRequest.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerServiceDiscoverEdgesRequest is the request for the DiscoverEdges method", - "patternProperties": { - "^(projectName)$": { - "description": "Restricts the answer to a project, the same way DiscoverPrivate does", - "type": "string" - }, - "^(projectVersion)$": { - "description": "Only meaningful alongside project_name: version names are unique within a project", - "type": "string" - } - }, - "properties": { - "nodes": { - "description": "The referrers to find connections between. A digest alone does not identify a referrer, so\n the kind travels with it.\n\n This is a set. A referrer may be listed more than once — a client that collected referrers\n from several attestations will have repeats — and is then reported at the position it first\n appeared in. At least two distinct referrers are required.", - "items": { - "$ref": "controlplane.v1.ReferrerRef.schema.json" - }, - "type": "array" - }, - "project_name": { - "description": "Restricts the answer to a project, the same way DiscoverPrivate does", - "type": "string" - }, - "project_version": { - "description": "Only meaningful alongside project_name: version names are unique within a project", - "type": "string" - } - }, - "title": "Referrer Service Discover Edges Request", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json deleted file mode 100644 index f0c4fe229..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesResponse.jsonschema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", - "properties": { - "edges": { - "description": "Each connection once, regardless of the direction it is stored in, and referred to by the\n first position its referrers were listed under in the request", - "items": { - "$ref": "controlplane.v1.ReferrerEdge.jsonschema.json" - }, - "type": "array" - } - }, - "title": "Referrer Service Discover Edges Response", - "type": "object" -} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json deleted file mode 100644 index 36b53c136..000000000 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$id": "controlplane.v1.ReferrerServiceDiscoverEdgesResponse.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "ReferrerServiceDiscoverEdgesResponse is the response for the DiscoverEdges method", - "properties": { - "edges": { - "description": "Each connection once, regardless of the direction it is stored in, and referred to by the\n first position its referrers were listed under in the request", - "items": { - "$ref": "controlplane.v1.ReferrerEdge.schema.json" - }, - "type": "array" - } - }, - "title": "Referrer Service Discover Edges Response", - "type": "object" -} diff --git a/app/controlplane/api/gen/openapi/openapi.yaml b/app/controlplane/api/gen/openapi/openapi.yaml index 6942a1b12..a5036f3b3 100644 --- a/app/controlplane/api/gen/openapi/openapi.yaml +++ b/app/controlplane/api/gen/openapi/openapi.yaml @@ -22,39 +22,6 @@ externalDocs: description: Chainloop Official Documentation url: https://docs.chainloop.dev paths: - /discover/edges: - post: - description: >- - Returns every connection between the given referrers that is visible to - the logged-in user, as pairs of indexes into the request. Referrers the - user cannot see contribute no edges. - operationId: ReferrerService_DiscoverEdges - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/v1ReferrerServiceDiscoverEdgesRequest' - description: Request to discover the edges between a set of referrers - required: true - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/v1ReferrerServiceDiscoverEdgesResponse' - description: A successful response. - default: - content: - application/json: - schema: - $ref: '#/components/schemas/rpcStatus' - description: An unexpected error response. - summary: Discover the edges between known referrers - tags: - - ReferrerService - x-codegen-request-body-name: body - security: - - bearerToken: [] /discover/{digest}: get: description: >- @@ -237,7 +204,7 @@ components: type: object rpcStatus: example: - code: 1 + code: 0 details: - '@type': '@type' - '@type': '@type' @@ -269,22 +236,6 @@ components: next_cursor: type: string type: object - v1ReferrerEdge: - description: A connection between two referrers, as indexes into the request - example: - from: 0 - to: 6 - properties: - from: - format: int64 - title: Index of the lower end of the connection, always smaller than to - type: integer - to: - format: int64 - title: Index of the higher end of the connection - type: integer - title: ReferrerEdge - type: object v1ReferrerItem: description: It represents a referrer object in the system example: @@ -334,68 +285,6 @@ components: type: object title: ReferrerItem type: object - v1ReferrerRef: - description: A reference to a referrer by digest and kind - properties: - digest: - title: Digest of the referrer, i.e sha256:deadbeef or sha1:beefdead - type: string - kind: - description: Kind of referrer, i.e CONTAINER_IMAGE, GIT_HEAD_COMMIT, ... - type: string - title: ReferrerRef - type: object - v1ReferrerServiceDiscoverEdgesRequest: - description: Request to discover the edges between a set of referrers - properties: - nodes: - description: >- - The referrers to find connections between. A digest alone does not - identify a referrer, so - - the kind travels with it. - - - This is a set. A referrer may be listed more than once — a client - that collected referrers - - from several attestations will have repeats — and is then reported - at the position it first - - appeared in. At least two distinct referrers are required. - items: - $ref: '#/components/schemas/v1ReferrerRef' - type: array - project_name: - title: Restricts the answer to a project, the same way DiscoverPrivate does - type: string - project_version: - title: >- - Only meaningful alongside project_name: version names are unique - within a project - type: string - title: ReferrerServiceDiscoverEdgesRequest - type: object - v1ReferrerServiceDiscoverEdgesResponse: - description: Response for the DiscoverEdges method - example: - edges: - - from: 0 - to: 6 - - from: 0 - to: 6 - properties: - edges: - items: - $ref: '#/components/schemas/v1ReferrerEdge' - title: >- - Each connection once, regardless of the direction it is stored in, - and referred to by the - - first position its referrers were listed under in the request - type: array - title: ReferrerServiceDiscoverEdgesResponse - type: object v1ReferrerServiceDiscoverPrivateResponse: description: Response for the DiscoverPrivate method example: diff --git a/app/controlplane/internal/service/referrer.go b/app/controlplane/internal/service/referrer.go index 333deea7e..883aca7db 100644 --- a/app/controlplane/internal/service/referrer.go +++ b/app/controlplane/internal/service/referrer.go @@ -136,56 +136,3 @@ func bizReferrerToPb(r *biz.StoredReferrer) *pb.ReferrerItem { return item } - -// DiscoverEdges returns the connections between referrers the caller already holds, so a client -// that has collected a set of them does not have to ask about each one separately. -func (s *ReferrerService) DiscoverEdges(ctx context.Context, req *pb.ReferrerServiceDiscoverEdgesRequest) (*pb.ReferrerServiceDiscoverEdgesResponse, error) { - currentUser, currentToken, err := requireCurrentUserOrAPIToken(ctx) - if err != nil { - return nil, err - } - - currentOrg, err := requireCurrentOrg(ctx) - if err != nil { - return nil, err - } - - nodes := make([]*biz.ReferrerRef, 0, len(req.GetNodes())) - for _, n := range req.GetNodes() { - nodes = append(nodes, &biz.ReferrerRef{Digest: n.GetDigest(), Kind: n.GetKind()}) - } - - var extraFilters []biz.GetFromRootFilter - if req.GetProjectName() != "" { - extraFilters = append(extraFilters, biz.WithProjectScope(req.GetProjectName(), req.GetProjectVersion())) - } - - var edges []biz.ReferrerEdge - switch { - case currentUser != nil: - edges, err = s.referrerUC.EdgesAmongUser(ctx, nodes, currentUser.ID, extraFilters...) - case currentToken != nil: - orgUUID, parseErr := uuid.Parse(currentOrg.ID) - if parseErr != nil { - return nil, fmt.Errorf("invalid org UUID: %w", parseErr) - } - - // An API token is scoped to one organization, and may be scoped to some of its projects. - orgsProjectsMap := make(map[uuid.UUID][]uuid.UUID) - if visibleProjects := s.visibleProjects(ctx); visibleProjects != nil { - orgsProjectsMap[orgUUID] = visibleProjects - } - - edges, err = s.referrerUC.EdgesAmong(ctx, nodes, []uuid.UUID{orgUUID}, orgsProjectsMap, extraFilters...) - } - if err != nil { - return nil, handleUseCaseErr(err, s.log) - } - - result := make([]*pb.ReferrerEdge, 0, len(edges)) - for _, e := range edges { - result = append(result, &pb.ReferrerEdge{From: uint32(e.From), To: uint32(e.To)}) - } - - return &pb.ReferrerServiceDiscoverEdgesResponse{Edges: result}, nil -} diff --git a/app/controlplane/internal/service/referrer_edges_integration_test.go b/app/controlplane/internal/service/referrer_edges_integration_test.go deleted file mode 100644 index dfc731dbe..000000000 --- a/app/controlplane/internal/service/referrer_edges_integration_test.go +++ /dev/null @@ -1,155 +0,0 @@ -// -// Copyright 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. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package service_test - -import ( - "bytes" - "context" - "encoding/json" - "os" - "testing" - - pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" - "github.com/chainloop-dev/chainloop/app/controlplane/internal/service" - "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" - "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/usercontext/entities" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/secure-systems-lab/go-securesystemslib/dsse" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -// The RPC decides which of two authorization paths to take and maps referrers onto the indexes -// the caller gave. Neither is exercised by the use-case tests, so both are covered here through -// the service itself. -func TestReferrerEdgesServiceIntegration(t *testing.T) { - suite.Run(t, new(referrerEdgesServiceSuite)) -} - -type referrerEdgesServiceSuite struct { - testhelpers.UseCasesEachTestSuite - - svc *service.ReferrerService - org *biz.Organization - user *biz.User - workflow *biz.Workflow - apiToken *biz.APIToken - attestation *biz.ReferrerRef - materials []*biz.ReferrerRef -} - -func (s *referrerEdgesServiceSuite) SetupTest() { - ctx := context.Background() - s.TestingUseCases = testhelpers.NewTestingUseCases(s.T()) - s.svc = service.NewReferrerService(s.Referrer) - - var err error - s.org, err = s.Organization.CreateWithRandomName(ctx) - require.NoError(s.T(), err) - s.user, err = s.User.UpsertByEmail(ctx, "edges@chainloop.local", nil) - require.NoError(s.T(), err) - _, err = s.Membership.Create(ctx, s.org.ID, s.user.ID, biz.WithCurrentMembership()) - require.NoError(s.T(), err) - - s.workflow, err = s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{ - OrgID: s.org.ID, Name: "edges-wf", Project: "edges-project", - }) - require.NoError(s.T(), err) - - s.apiToken, err = s.APIToken.Create(ctx, "edges-token", nil, nil, &s.org.ID) - require.NoError(s.T(), err) - - envelope, raw := edgesEnvelope(s.T()) - digest, _, err := v1.SHA256(bytes.NewReader(raw)) - require.NoError(s.T(), err) - require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, digest, s.workflow.ID.String())) - - root, _, err := s.Referrer.GetFromRootUser(ctx, digest.String(), "", s.user.ID, nil) - require.NoError(s.T(), err) - require.NotEmpty(s.T(), root.References) - - s.attestation = &biz.ReferrerRef{Digest: root.Digest, Kind: root.Kind} - for _, ref := range root.References { - s.materials = append(s.materials, &biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}) - } -} - -func (s *referrerEdgesServiceSuite) userCtx() context.Context { - ctx := entities.WithCurrentUser(context.Background(), &entities.User{ID: s.user.ID, Email: s.user.Email}) - return entities.WithCurrentOrg(ctx, &entities.Org{ID: s.org.ID, Name: s.org.Name}) -} - -func (s *referrerEdgesServiceSuite) tokenCtx() context.Context { - ctx := entities.WithCurrentOrg(context.Background(), &entities.Org{ID: s.org.ID, Name: s.org.Name}) - ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: s.apiToken.ID.String(), Name: s.apiToken.Name}) - return usercontext.WithAuthzSubject(ctx, (&authz.SubjectAPIToken{ID: s.apiToken.ID.String()}).String()) -} - -func (s *referrerEdgesServiceSuite) request(nodes ...*biz.ReferrerRef) *pb.ReferrerServiceDiscoverEdgesRequest { - out := make([]*pb.ReferrerRef, 0, len(nodes)) - for _, n := range nodes { - out = append(out, &pb.ReferrerRef{Digest: n.Digest, Kind: n.Kind}) - } - return &pb.ReferrerServiceDiscoverEdgesRequest{Nodes: out} -} - -func (s *referrerEdgesServiceSuite) TestDiscoverEdges() { - s.Run("a logged-in user gets one edge per material, indexed by request position", func() { - nodes := append([]*biz.ReferrerRef{s.attestation}, s.materials...) - res, err := s.svc.DiscoverEdges(s.userCtx(), s.request(nodes...)) - s.Require().NoError(err) - s.Len(res.GetEdges(), len(s.materials)) - for _, e := range res.GetEdges() { - s.Less(e.GetFrom(), e.GetTo(), "indexes come back normalised") - s.EqualValues(0, e.GetFrom(), "the attestation is at index 0 of this request") - } - }) - - s.Run("a referrer the caller cannot see contributes no edges", func() { - unknown := &biz.ReferrerRef{Digest: "sha256:" + "00", Kind: "ATTESTATION"} - res, err := s.svc.DiscoverEdges(s.userCtx(), s.request(s.attestation, unknown)) - s.Require().NoError(err) - s.Empty(res.GetEdges(), "an unresolvable node must not fail the call") - }) - - s.Run("no credential is refused", func() { - _, err := s.svc.DiscoverEdges(context.Background(), s.request(s.attestation, s.materials[0])) - s.Error(err) - }) - - s.Run("an API token resolves through its own organization", func() { - nodes := append([]*biz.ReferrerRef{s.attestation}, s.materials...) - res, err := s.svc.DiscoverEdges(s.tokenCtx(), s.request(nodes...)) - s.Require().NoError(err) - s.Len(res.GetEdges(), len(s.materials), "the token path must see what the user path sees") - }) - - // Project scoping is deliberately not covered here. It resolves through workflow runs, and - // this fixture has none, so every project name would answer the same way and the assertion - // would hold for the wrong reason. It belongs where a run fixture already exists. -} - -func edgesEnvelope(t *testing.T) (*dsse.Envelope, []byte) { - raw, err := os.ReadFile("../../pkg/biz/testdata/attestations/with-git-subject.json") - require.NoError(t, err) - var envelope *dsse.Envelope - require.NoError(t, json.Unmarshal(raw, &envelope)) - - return envelope, raw -} diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 0118305a8..471338e60 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -362,7 +362,6 @@ var RolesMap = map[Role][]*Policy{ var ServerOperationsMap = map[string]*OperationPolicy{ // Discover endpoint "/controlplane.v1.ReferrerService/DiscoverPrivate": {Policies: []*Policy{PolicyReferrerRead}}, - "/controlplane.v1.ReferrerService/DiscoverEdges": {Policies: []*Policy{PolicyReferrerRead}}, // Download/Uploading artifacts // There are no policies for the download endpoint, we do a manual check in the service layer // to differentiate between upload and download requests diff --git a/app/controlplane/pkg/biz/referrer.go b/app/controlplane/pkg/biz/referrer.go index 2731caeb1..06bbfb019 100644 --- a/app/controlplane/pkg/biz/referrer.go +++ b/app/controlplane/pkg/biz/referrer.go @@ -69,22 +69,6 @@ type ReferrerRepo interface { // 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) - // EdgesAmong returns the connections between the given referrers, as index pairs into nodes. - // Referrers the caller cannot see contribute no edges. Each connection is reported once even - // though the store holds both of its directions. - EdgesAmong(ctx context.Context, nodes []*ReferrerRef, orgIDs []uuid.UUID, filters ...GetFromRootFilter) ([]ReferrerEdge, error) -} - -// ReferrerRef identifies a referrer. A digest alone is not enough: the same digest can be stored -// under more than one kind. -type ReferrerRef struct { - Digest, Kind string -} - -// ReferrerEdge is a connection between two referrers, given as indexes into the slice the caller -// asked about. From is always the smaller index. -type ReferrerEdge struct { - From, To int } type Referrer struct { @@ -226,59 +210,6 @@ func (s *ReferrerUseCase) GetFromRoot(ctx context.Context, digest, rootKind stri return ref, nextCursor, nil } -// EdgesAmongUser returns the connections between the given referrers that the user is allowed to -// see. It is the companion of GetFromRootUser: a caller that has already collected a set of -// referrers uses it to learn how they connect, instead of asking about each one separately. -func (s *ReferrerUseCase) EdgesAmongUser(ctx context.Context, nodes []*ReferrerRef, userID string, extraFilters ...GetFromRootFilter) ([]ReferrerEdge, error) { - ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.EdgesAmongUser") - defer span.End() - - userUUID, err := uuid.Parse(userID) - if err != nil { - return nil, NewErrInvalidUUID(err) - } - - userOrgs, scopes, err := s.membershipUseCase.GetOrgsAndRBACInfoForUser(ctx, userUUID) - if err != nil { - return nil, err - } - - return s.EdgesAmong(ctx, nodes, userOrgs, scopes.ProjectIDsByOrg(), extraFilters...) -} - -// EdgesAmong returns the connections between the given referrers visible in the given orgs. -func (s *ReferrerUseCase) EdgesAmong(ctx context.Context, nodes []*ReferrerRef, orgIDs []uuid.UUID, projectIDs map[OrgID][]ProjectID, extraFilters ...GetFromRootFilter) ([]ReferrerEdge, error) { - ctx, span := otelx.Start(ctx, referrerTracer, "ReferrerUseCase.EdgesAmong") - defer span.End() - - // nodes is a set. Naming the same referrer twice is accepted — a client that collected - // referrers from more than one attestation will have repeats — but fewer than two distinct - // ones describes no connection, and is a mistake rather than a question. - distinct := make(map[ReferrerRef]struct{}, len(nodes)) - for _, n := range nodes { - if n == nil { - return nil, NewErrValidationStr("a referrer is missing") - } - distinct[*n] = struct{}{} - } - if len(distinct) < 2 { - return nil, NewErrValidationStr("at least two distinct referrers are required") - } - - filters := make([]GetFromRootFilter, 0, len(extraFilters)+1) - if projectIDs != nil { - filters = append(filters, WithVisibleProjectIDs(projectIDs)) - } - filters = append(filters, extraFilters...) - - edges, err := s.repo.EdgesAmong(ctx, nodes, orgIDs, filters...) - if err != nil { - return nil, fmt.Errorf("getting edges: %w", err) - } - - return edges, nil -} - const ( // ReferrerAttestationType is the kind of the referrer that represents an attestation. ReferrerAttestationType = "ATTESTATION" diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 9a6e86e06..355413601 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -654,206 +654,36 @@ func TestReferrerIntegration(t *testing.T) { suite.Run(t, new(referrerIntegrationTestSuite)) } -// TestEdgesAmong covers the endpoint a client uses once it already holds a set of referrers and -// only needs to know how they connect. -func (s *referrerIntegrationTestSuite) TestEdgesAmong() { +// Project visibility is not an optional filter: a member restricted to a subset of an +// organization's projects must not reach a referrer outside them, and a grant naming one +// organization must not reach a project living in another. +func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVisibility() { ctx := context.Background() envelope, envBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") attDigest, _, err := v1.SHA256(bytes.NewReader(envBytes)) require.NoError(s.T(), err) require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, envelope, attDigest, s.workflow1.ID.String())) + digest := attDigest.String() - // Read back what the attestation is connected to, so the test does not hardcode the fixture. - root, _, err := s.Referrer.GetFromRootUser(ctx, attDigest.String(), "", s.user.ID, nil) - require.NoError(s.T(), err) - require.NotEmpty(s.T(), root.References) - - attestation := &biz.ReferrerRef{Digest: root.Digest, Kind: root.Kind} - materials := make([]*biz.ReferrerRef, 0, len(root.References)) - for _, ref := range root.References { - materials = append(materials, &biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}) - } - - s.Run("every material is reported as connected to the attestation, once", func() { - nodes := append([]*biz.ReferrerRef{attestation}, materials...) - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) + s.Run("a caller granted the project reaches it", func() { + granted := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} + got, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID}, granted, nil) s.NoError(err) - s.Len(edges, len(materials), "one edge per material, not one per stored direction") - - for _, e := range edges { - s.Less(e.From, e.To, "edges are normalised so the lower index comes first") - s.Equal(0, e.From, "every edge in this fixture hangs off the attestation at index 0") - } + s.Equal(digest, got.Digest) }) - // The caller's role-based project visibility is not an optional filter: a member who cannot - // reach the project must not learn that two of its referrers are connected, whether or not - // they name a project in the request. - s.Run("a caller restricted to no project in the org gets no edges", func() { - nodes := append([]*biz.ReferrerRef{attestation}, materials...) - // RBAC on for the org, nothing granted in it. + s.Run("a caller restricted to no project in the organization does not", func() { noProjects := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {}} - edges, err := s.Referrer.EdgesAmong(ctx, nodes, []uuid.UUID{s.org1UUID}, noProjects) - s.NoError(err) - s.Empty(edges, "org membership alone must not reveal connections") - - // And the established endpoint agrees for the same caller. - _, _, err = s.Referrer.GetFromRoot(ctx, attestation.Digest, attestation.Kind, []uuid.UUID{s.org1UUID}, noProjects, nil) - s.Error(err, "the reference endpoint denies this caller, so the edges endpoint must too") + _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID}, noProjects, nil) + s.Error(err) }) - // The grant names an organization as well as a project, and a project is reached through the - // organization it belongs to. Matching the two sets independently would let a project granted - // in one organization be reached from another the caller happens to share. - s.Run("a grant recorded against another organization does not reach the project", func() { - nodes := append([]*biz.ReferrerRef{attestation}, materials...) + s.Run("a grant recorded against another organization does not", func() { crossOrg := map[biz.OrgID][]biz.ProjectID{ s.org1UUID: {}, // restricted here, nothing granted s.org2UUID: {s.workflow1.ProjectID}, // granted here, but the project lives in org1 } - orgs := []uuid.UUID{s.org1UUID, s.org2UUID} - - edges, err := s.Referrer.EdgesAmong(ctx, nodes, orgs, crossOrg) - s.NoError(err) - s.Empty(edges, "the project belongs to an organization where nothing is granted") - - _, _, err = s.Referrer.GetFromRoot(ctx, attestation.Digest, attestation.Kind, orgs, crossOrg, nil) - s.Error(err, "the reference endpoint must refuse it for the same reason") - }) - - s.Run("a caller granted the project still gets every edge", func() { - nodes := append([]*biz.ReferrerRef{attestation}, materials...) - granted := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} - edges, err := s.Referrer.EdgesAmong(ctx, nodes, []uuid.UUID{s.org1UUID}, granted) - s.NoError(err) - s.Len(edges, len(materials), "a granted project must not lose any connection") - }) - - s.Run("the order the caller gives is the order the indexes refer to", func() { - // Same set, attestation last: the indexes must follow. - nodes := append(append([]*biz.ReferrerRef{}, materials...), attestation) - attestationIndex := len(nodes) - 1 - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) - s.NoError(err) - s.Len(edges, len(materials)) - for _, e := range edges { - s.Equal(attestationIndex, e.To, "the attestation is now the higher index of every pair") - } - }) - - s.Run("materials alone have no edges between them", func() { - if len(materials) < 2 { - s.T().Skip("fixture has a single material") - } - edges, err := s.Referrer.EdgesAmongUser(ctx, materials, s.user.ID) - s.NoError(err) - s.Empty(edges, "materials are connected through their attestation, not to each other") - }) - - s.Run("a referrer the caller cannot see contributes no edges", func() { - nodes := append([]*biz.ReferrerRef{attestation}, materials...) - // user2 belongs to org2 only, where none of this was ingested. - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user2.ID) - s.NoError(err) - s.Empty(edges) - }) - - s.Run("an unknown referrer is ignored rather than failing", func() { - nodes := []*biz.ReferrerRef{ - attestation, - {Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", Kind: "ATTESTATION"}, - } - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) - s.NoError(err) - s.Empty(edges) - }) - - s.Run("the kind is part of the identity", func() { - // The same digest under a kind it was not stored as must not resolve. - nodes := []*biz.ReferrerRef{ - {Digest: attestation.Digest, Kind: "CONTAINER_IMAGE"}, - materials[0], - } - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) - s.NoError(err) - s.Empty(edges) - }) - - s.Run("fewer than two referrers is refused without touching the store", func() { - _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation}, s.user.ID) + _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID, s.org2UUID}, crossOrg, nil) s.Error(err) - s.True(biz.IsErrValidation(err), "one referrer describes no connection") }) - - // A client that collected referrers from more than one attestation will list a shared - // material twice. That is accepted, and the answer names the position it first appeared in. - s.Run("a referrer listed twice is answered at its first position", func() { - nodes := []*biz.ReferrerRef{attestation, materials[0], materials[0]} - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) - s.NoError(err) - s.Equal([]biz.ReferrerEdge{{From: 0, To: 1}}, edges, "index 2 repeats index 1, which is the one named") - }) - - // Identity is the pair, not the two joined together: kind is whatever the caller sent, so a - // separator inside it must not make two different referrers count as one. - s.Run("referrers that differ only in where the kind ends are two referrers", func() { - nodes := []*biz.ReferrerRef{{Kind: "A", Digest: "B-C"}, {Kind: "A-B", Digest: "C"}} - edges, err := s.Referrer.EdgesAmongUser(ctx, nodes, s.user.ID) - s.NoError(err, "these are two distinct referrers and must not be refused as one") - s.Empty(edges, "neither exists, so neither contributes an edge") - }) - - s.Run("a missing referrer is refused rather than dereferenced", func() { - _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, nil}, s.user.ID) - s.Error(err) - s.True(biz.IsErrValidation(err)) - }) - - s.Run("the same referrer twice is not two referrers", func() { - _, err := s.Referrer.EdgesAmongUser(ctx, []*biz.ReferrerRef{attestation, attestation}, s.user.ID) - s.Error(err, "two positions naming one referrer describe no connection") - s.True(biz.IsErrValidation(err)) - }) -} - -// TestEveryConnectionIsWrittenFromTheAttestation pins the property EdgesAmong depends on to stay -// bounded: it reads connections from the attestation side, so ingest must always write that row. -// A future change that only recorded the material's side would make edges disappear from the -// graph rather than fail loudly. -func (s *referrerIntegrationTestSuite) TestEveryConnectionIsWrittenFromTheAttestation() { - ctx := context.Background() - - // Cover the shapes ingest produces: materials, a git subject, and a dependent attestation. - dependent, _ := testEnvelope(s.T(), "testdata/attestations/dependent-attestation.json") - dependentDigest, _ := v1.NewHash("sha256:2dc17f7c933d20e06b49250a582a3d19bdfbadba9c4e5f3f856af6f261db79d4") - require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, dependent, dependentDigest, s.workflow1.ID.String())) - - withDependent, _ := testEnvelope(s.T(), "testdata/attestations/with-dependent-attestation.json") - withDependentDigest, _ := v1.NewHash("sha256:950c7b4c65447a3b86b6f769515005e7c44a67c8193bff790750eadf13207fbb") - require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, withDependent, withDependentDigest, s.workflow1.ID.String())) - - gitSubject, gitBytes := testEnvelope(s.T(), "testdata/attestations/with-git-subject.json") - gitDigest, _, err := v1.SHA256(bytes.NewReader(gitBytes)) - require.NoError(s.T(), err) - require.NoError(s.T(), s.Referrer.ExtractAndPersist(ctx, gitSubject, gitDigest, s.workflow1.ID.String())) - - // Every stored connection must have a row whose owning side is an attestation. - var orphaned int - row := s.Data.SQLDB.QueryRowContext(ctx, ` - SELECT count(*) FROM referrer_references rr - JOIN referrers owner ON owner.id = rr.referrer_id - WHERE owner.kind <> 'ATTESTATION' - AND NOT EXISTS ( - SELECT 1 FROM referrer_references mirror - JOIN referrers mirror_owner ON mirror_owner.id = mirror.referrer_id - WHERE mirror.referrer_id = rr.referred_by_id - AND mirror.referred_by_id = rr.referrer_id - AND mirror_owner.kind = 'ATTESTATION' - )`) - require.NoError(s.T(), row.Scan(&orphaned)) - s.Zero(orphaned, "every connection must be reachable from the attestation that created it") - - var total int - require.NoError(s.T(), s.Data.SQLDB.QueryRowContext(ctx, `SELECT count(*) FROM referrer_references`).Scan(&total)) - s.NotZero(total, "the fixture must actually have written connections") } diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index 14232ba8b..5866bde9c 100644 --- a/app/controlplane/pkg/data/referrer.go +++ b/app/controlplane/pkg/data/referrer.go @@ -20,7 +20,6 @@ import ( "fmt" "slices" - "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" @@ -286,46 +285,6 @@ func referrerVisibleToOrgs(orgIDs []uuid.UUID) predicate.Referrer { } } -// referrerVisibleInProjects narrows referrerVisibleToOrgs to the projects the caller can -// actually see. Organization membership alone is not visibility: a member with role-based -// restrictions reaches only the projects granted to them, and a referrer that belongs to no -// other project must not be reachable through any endpoint. -// -// The project is matched on its own row rather than on the workflow's organization column, so a -// grant cannot be satisfied by a project that lives in a different organization to the one it -// was granted in. -func referrerVisibleInProjects(orgIDs []uuid.UUID, visibleProjectsMap map[uuid.UUID][]uuid.UUID) predicate.Referrer { - scope := projectVisibilityPredicate(orgIDs, visibleProjectsMap) - if scope == nil { - // No organization grants any project, so nothing is visible. - return func(s *sql.Selector) { s.Where(sql.False()) } - } - - return func(s *sql.Selector) { - joinTable := sql.Table(referrer.WorkflowsTable).As("scoped_rw") - workflows := sql.Table(referrer.WorkflowsInverseTable).As("scoped_wf") - projects := sql.Table(project.Table).As("scoped_p") - - visibleProject := sql.Dialect(s.Dialect()). - Select(projects.C(project.FieldID)). - From(projects). - Where(sql.ColumnsEQ(projects.C(project.FieldID), workflows.C(workflow.FieldProjectID))) - scope(visibleProject) - - sub := sql.Dialect(s.Dialect()). - Select(joinTable.C(referrer.WorkflowsPrimaryKey[0])). - From(joinTable). - Join(workflows). - On(joinTable.C(referrer.WorkflowsPrimaryKey[1]), workflows.C(workflow.FieldID)). - Where(sql.And( - sql.ColumnsEQ(joinTable.C(referrer.WorkflowsPrimaryKey[0]), s.C(referrer.FieldID)), - sql.IsNull(workflows.C(workflow.FieldDeletedAt)), - sql.Exists(visibleProject), - )) - s.Where(sql.Exists(sub)) - } -} - // projectVisibilityPredicate builds a project predicate that accepts a project iff it belongs to // one of the allowed orgs AND, when RBAC applies to that org, the project is in the caller's // visible set. Returns nil when no org grants any project visibility, so callers can treat that @@ -593,178 +552,3 @@ func hydrateWorkflowsInfo(root *ent.Referrer, out *biz.StoredReferrer) { out.WorkflowIDs = workflowIDs out.OrgIDs = orgIDs } - -// EdgesAmong returns the connections between the given referrers, as index pairs into nodes. -// -// The work is bounded by the number of referrers asked about, not by how many references any of -// them has: a shared material can be referenced by a hundred thousand attestations, and asking -// "which of these hundred nodes are connected" must not walk that. It therefore reads the store -// from the attestation side of each connection, whose degree is the number of materials in one -// attestation rather than the number of attestations sharing a material. -// -// That is complete because of how connections are written: a connection only ever exists between -// an attestation and one of its materials or subjects (or another attestation it depends on), and -// the attestation's own row is always written, since ingest builds its reference list from every -// material and subject it carries. The material's row pointing back is written too, but it is not -// what this relies on — older data holds many connections in the attestation direction only. -func (r *ReferrerRepo) EdgesAmong(ctx context.Context, nodes []*biz.ReferrerRef, orgIDs []uuid.UUID, filters ...biz.GetFromRootFilter) ([]biz.ReferrerEdge, error) { - ctx, span := otelx.Start(ctx, referrerRepoTracer, "ReferrerRepo.EdgesAmong") - defer span.End() - - if len(nodes) < 2 { - return nil, nil - } - - opts := &biz.GetFromRootFilters{} - for _, f := range filters { - f(opts) - } - - // Resolve the referrers the caller may see. Anything invisible or unknown simply does not - // come back and so contributes no edges. - // Role-based project visibility applies to every request, not only to the ones that name a - // project: the optional filter below narrows what the caller asked for, it does not decide - // what they are allowed to see. - predicates := []predicate.Referrer{ - referrerDigestKindIn(nodes), - referrerVisibleInProjects(orgIDs, opts.ProjectIDs), - } - if opts.ProjectName != nil && *opts.ProjectName != "" { - version := "" - if opts.ProjectVersion != nil { - version = *opts.ProjectVersion - } - projectPred := r.projectScopePredicate(*opts.ProjectName, version, orgIDs, opts.ProjectIDs) - predicates = append(predicates, referrer.Or(referrer.KindNEQ(biz.ReferrerAttestationType), projectPred)) - } - - visible, err := r.data.DB.Referrer.Query(). - Where(predicates...). - Select(referrer.FieldID, referrer.FieldDigest, referrer.FieldKind). - All(ctx) - if err != nil { - return nil, fmt.Errorf("failed to resolve referrers: %w", err) - } - if len(visible) < 2 { - return nil, nil - } - - // Index the answer by the position the caller gave each referrer, so only positions travel back. - // A referrer the caller listed more than once is answered at the position it first appeared - // in, so the answer does not depend on the order the map happens to be written in. - // Keyed by the pair itself rather than by the two joined into a string: kind is whatever the - // caller sent, so any separator could appear inside it and make two different referrers look - // like one. - indexOf := make(map[biz.ReferrerRef]int, len(nodes)) - for i, n := range nodes { - if _, seen := indexOf[*n]; !seen { - indexOf[*n] = i - } - } - position := make(map[uuid.UUID]int, len(visible)) - attestationIDs := make([]uuid.UUID, 0, len(visible)) - visibleIDs := make([]uuid.UUID, 0, len(visible)) - for _, ref := range visible { - i, ok := indexOf[biz.ReferrerRef{Digest: ref.Digest, Kind: ref.Kind}] - if !ok { - continue - } - position[ref.ID] = i - visibleIDs = append(visibleIDs, ref.ID) - if ref.Kind == biz.ReferrerAttestationType { - attestationIDs = append(attestationIDs, ref.ID) - } - } - if len(attestationIDs) == 0 || len(visibleIDs) < 2 { - return nil, nil - } - - // Built with the dialect's builder so the placeholders are numbered for it, and run on the - // raw handle because the join table has no entity of its own to query through. - query, args := edgesAmongQuery(attestationIDs, visibleIDs) - rows, err := r.data.SQLDB.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("failed to query edges: %w", err) - } - defer rows.Close() - - // A connection can be read twice: once from each end when both ends are attestations, and - // once per stored direction where ingest wrote both. The pair is normalised and deduplicated - // so the caller sees each connection once. - seen := make(map[biz.ReferrerEdge]struct{}) - edges := make([]biz.ReferrerEdge, 0) - for rows.Next() { - var from, to uuid.UUID - if err := rows.Scan(&from, &to); err != nil { - return nil, fmt.Errorf("failed to scan edge: %w", err) - } - a, okA := position[from] - b, okB := position[to] - if !okA || !okB || a == b { - continue - } - if a > b { - a, b = b, a - } - edge := biz.ReferrerEdge{From: a, To: b} - if _, dup := seen[edge]; dup { - continue - } - seen[edge] = struct{}{} - edges = append(edges, edge) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("failed to read edges: %w", err) - } - - return edges, nil -} - -// referrerDigestKindIn matches any of the given referrers, pairing each digest with its kind so a -// digest stored under two kinds cannot be confused for the other. It is a single composite IN -// rather than a chain of ORs, which keeps it usable through the unique index on (digest, kind). -func referrerDigestKindIn(nodes []*biz.ReferrerRef) predicate.Referrer { - return func(s *sql.Selector) { - s.Where(sql.P().Append(func(b *sql.Builder) { - b.Wrap(func(nb *sql.Builder) { - nb.IdentComma(s.C(referrer.FieldDigest), s.C(referrer.FieldKind)) - }) - b.WriteString(" IN ") - b.Wrap(func(nb *sql.Builder) { - for i, n := range nodes { - if i > 0 { - nb.Comma() - } - nb.Wrap(func(vb *sql.Builder) { - vb.Args(n.Digest, n.Kind) - }) - } - }) - })) - } -} - -// edgesAmongQuery reads the join table from the attestation side: for each attestation it walks -// only that attestation's own references, restricted to the referrers the caller asked about. -// Both columns are the table's primary key, so this is an index scan whose size is the number of -// materials in the attestations involved. -func edgesAmongQuery(fromIDs, toIDs []uuid.UUID) (string, []any) { - t := sql.Dialect(dialect.Postgres).Table(referrer.ReferencesTable) - from := make([]any, 0, len(fromIDs)) - for _, id := range fromIDs { - from = append(from, id) - } - to := make([]any, 0, len(toIDs)) - for _, id := range toIDs { - to = append(to, id) - } - - return sql.Dialect(dialect.Postgres). - Select(referrer.ReferencesPrimaryKey[0], referrer.ReferencesPrimaryKey[1]). - From(t). - Where(sql.And( - sql.In(referrer.ReferencesPrimaryKey[0], from...), - sql.In(referrer.ReferencesPrimaryKey[1], to...), - )). - Query() -}