diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index e73eb6207..355413601 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -653,3 +653,37 @@ func (s *referrerIntegrationTestSuite) SetupTest() { func TestReferrerIntegration(t *testing.T) { suite.Run(t, new(referrerIntegrationTestSuite)) } + +// 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() + + 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.Equal(digest, got.Digest) + }) + + s.Run("a caller restricted to no project in the organization does not", func() { + noProjects := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {}} + _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID}, noProjects, nil) + s.Error(err) + }) + + 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 + } + _, _, err := s.Referrer.GetFromRoot(ctx, digest, "", []uuid.UUID{s.org1UUID, s.org2UUID}, crossOrg, nil) + s.Error(err) + }) +} diff --git a/app/controlplane/pkg/data/export_test.go b/app/controlplane/pkg/data/export_test.go new file mode 100644 index 000000000..6119e652c --- /dev/null +++ b/app/controlplane/pkg/data/export_test.go @@ -0,0 +1,22 @@ +// +// 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 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 diff --git a/app/controlplane/pkg/data/referrer.go b/app/controlplane/pkg/data/referrer.go index a642d3ac0..5866bde9c 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" @@ -97,10 +96,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) @@ -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 @@ -216,10 +210,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 @@ -262,30 +254,114 @@ 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 +// 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 { + 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 // 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 @@ -331,12 +407,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: @@ -358,6 +430,18 @@ 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, 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())). Limit(p.Limit + 1) // fetch limit+1 to detect next page @@ -407,7 +491,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 new file mode 100644 index 000000000..e23a11f45 --- /dev/null +++ b/app/controlplane/pkg/data/referrer_test.go @@ -0,0 +1,228 @@ +// +// 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/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/referrer" + "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 +} + +// 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++ { + 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, 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) + + // 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) + } + }) + } +} + +// 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) +} + +// 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") + } +} 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 +}