From 91554e4f14912d752cd8ebe70608a0455c1952a6 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Fri, 17 Jul 2026 01:15:15 +0400 Subject: [PATCH] fix(api): scope workspace search to visible projects and pages Search authorized only workspace membership and filtered the SQL by workspace_id, so a plain member (or guest) got back issues, cycles, modules, views and names from secret projects they weren't on, plus the titles of other users' private pages. Add a project-visibility predicate (public, or the caller is a project member or a workspace admin/owner) to every project-joined query, mirroring ProjectService.GetByID, and restrict page hits to public pages or ones the caller owns. Covered by a test that a non-member can't find a secret project's issue or the project itself while a member can. Closes #324 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/service/search.go | 2 +- .../service/search_visibility_test.go | 55 +++++++++++++++++++ apps/api/internal/store/search.go | 23 +++++++- 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 apps/api/internal/service/search_visibility_test.go diff --git a/apps/api/internal/service/search.go b/apps/api/internal/service/search.go index 10f88571..64d13c49 100644 --- a/apps/api/internal/service/search.go +++ b/apps/api/internal/service/search.go @@ -42,5 +42,5 @@ func (s *SearchService) Search(ctx context.Context, workspaceSlug, query string, if q == "" { return store.EmptyResults(), nil } - return s.ss.Search(ctx, wrk.ID, q, projectID, searchPerGroupLimit) + return s.ss.Search(ctx, wrk.ID, q, projectID, userID, searchPerGroupLimit) } diff --git a/apps/api/internal/service/search_visibility_test.go b/apps/api/internal/service/search_visibility_test.go new file mode 100644 index 00000000..6a9279ec --- /dev/null +++ b/apps/api/internal/service/search_visibility_test.go @@ -0,0 +1,55 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +// Workspace search must not return content from a secret project to a member +// who isn't on that project. Previously it authorized only workspace membership +// and filtered the SQL by workspace_id alone. +func TestSearchRespectsProjectVisibility(t *testing.T) { + ts := testutil.NewTestServer(t) + db := ts.DB + ctx := context.Background() + + owner := testutil.CreateUser(t, db) + wrk := testutil.CreateWorkspace(t, db, owner.ID) // owner is also the project lead/member + + outsider := testutil.CreateUser(t, db) + testutil.AddWorkspaceMember(t, db, wrk.ID, outsider.ID, model.RoleMember) + + proj := testutil.CreateProject(t, db, wrk.ID, owner.ID) + const projToken = "zqxsecretproj" + require.NoError(t, db.Model(&model.Project{}).Where("id = ?", proj.ID). + Updates(map[string]any{"network": model.NetworkSecret, "name": projToken}).Error) + + issue := testutil.CreateIssue(t, db, proj.ID, wrk.ID, owner.ID) + const issueToken = "zqxsecretwidget" + require.NoError(t, db.Model(&model.Issue{}).Where("id = ?", issue.ID). + Update("name", issueToken).Error) + + svc := service.NewSearchService(store.NewSearchStore(db), store.NewWorkspaceStore(db)) + + // Outsider is a workspace member but not on the secret project. + out, err := svc.Search(ctx, wrk.Slug, issueToken, nil, outsider.ID) + require.NoError(t, err) + require.Empty(t, out.Issues, "outsider must not see a secret project's issue") + outProj, err := svc.Search(ctx, wrk.Slug, projToken, nil, outsider.ID) + require.NoError(t, err) + require.Empty(t, outProj.Projects, "outsider must not see the secret project itself") + + // The owner (a project member) still finds both. + own, err := svc.Search(ctx, wrk.Slug, issueToken, nil, owner.ID) + require.NoError(t, err) + require.Len(t, own.Issues, 1, "project member should see the issue") + ownProj, err := svc.Search(ctx, wrk.Slug, projToken, nil, owner.ID) + require.NoError(t, err) + require.Len(t, ownProj.Projects, 1, "project member should see the project") +} diff --git a/apps/api/internal/store/search.go b/apps/api/internal/store/search.go index 10339370..c8ec565d 100644 --- a/apps/api/internal/store/search.go +++ b/apps/api/internal/store/search.go @@ -4,10 +4,22 @@ import ( "context" "strings" + "github.com/Devlaner/devlane/api/internal/model" "github.com/google/uuid" "gorm.io/gorm" ) +// projectVisibleClause restricts a query joined to the projects table (aliased +// `alias`) to projects the user may see: public projects, projects they belong +// to, or any project when they're a workspace admin/owner. Mirrors the gate in +// ProjectService.GetByID so search can't leak secret-project content. +func projectVisibleClause(alias string, userID uuid.UUID) (string, []interface{}) { + clause := "(" + alias + ".network = ?" + + " OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = " + alias + ".id AND pm.member_id = ? AND pm.deleted_at IS NULL)" + + " OR EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = " + alias + ".workspace_id AND wm.member_id = ? AND wm.role >= ? AND wm.deleted_at IS NULL))" + return clause, []interface{}{model.NetworkPublic, userID, userID, model.RoleAdmin} +} + // SearchStore runs cross-entity text search within a workspace. It is pure DB: // callers are responsible for authorizing the workspace/user first. type SearchStore struct{ db *gorm.DB } @@ -63,9 +75,10 @@ func likePattern(q string) string { // non-nil the project-owned entities (issues, epics, cycles, modules, views) // are scoped to that project and the workspace-level groups (pages, projects) // are left empty. limit caps the number of hits per group. -func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query string, projectID *uuid.UUID, limit int) (SearchResults, error) { +func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query string, projectID *uuid.UUID, userID uuid.UUID, limit int) (SearchResults, error) { res := EmptyResults() pat := likePattern(query) + visClause, visArgs := projectVisibleClause("p", userID) // Work items / epics: match by name or "IDENTIFIER-seq" (e.g. "DEV-42"). searchIssues := func(epic bool, dst *[]SearchHit) error { @@ -73,7 +86,8 @@ func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query s Select("i.id, i.name, i.project_id, i.sequence_id, p.identifier AS project_identifier"). Joins("JOIN projects p ON p.id = i.project_id AND p.deleted_at IS NULL"). Where("i.workspace_id = ? AND i.deleted_at IS NULL AND i.archived_at IS NULL AND i.is_epic = ?", workspaceID, epic). - Where("(i.name ILIKE ? OR (p.identifier || '-' || i.sequence_id::text) ILIKE ?)", pat, pat) + Where("(i.name ILIKE ? OR (p.identifier || '-' || i.sequence_id::text) ILIKE ?)", pat, pat). + Where(visClause, visArgs...) if projectID != nil { q = q.Where("i.project_id = ?", *projectID) } @@ -92,7 +106,8 @@ func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query s q := s.db.WithContext(ctx).Table(table+" AS e"). Select("e.id, e.name, e.project_id, p.identifier AS project_identifier"). Joins("JOIN projects p ON p.id = e.project_id AND p.deleted_at IS NULL"). - Where("e.workspace_id = ? AND e.deleted_at IS NULL AND e.name ILIKE ?", workspaceID, pat) + Where("e.workspace_id = ? AND e.deleted_at IS NULL AND e.name ILIKE ?", workspaceID, pat). + Where(visClause, visArgs...) if hasArchived { q = q.Where("e.archived_at IS NULL") } @@ -123,6 +138,7 @@ func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query s (SELECT pp.project_id FROM project_pages pp WHERE pp.page_id = pg.id AND pp.deleted_at IS NULL ORDER BY pp.created_at ASC LIMIT 1) AS project_id, (SELECT p2.identifier FROM project_pages pp JOIN projects p2 ON p2.id = pp.project_id WHERE pp.page_id = pg.id AND pp.deleted_at IS NULL ORDER BY pp.created_at ASC LIMIT 1) AS project_identifier`). Where("pg.workspace_id = ? AND pg.deleted_at IS NULL AND pg.archived_at IS NULL AND pg.name ILIKE ?", workspaceID, pat). + Where("(pg.access = ? OR pg.owned_by_id = ?)", model.PageAccessPublic, userID). Where("EXISTS (SELECT 1 FROM project_pages pp WHERE pp.page_id = pg.id AND pp.deleted_at IS NULL)"). Order("pg.name ASC").Limit(limit).Scan(&res.Pages).Error; err != nil { return res, err @@ -132,6 +148,7 @@ func (s *SearchStore) Search(ctx context.Context, workspaceID uuid.UUID, query s if err := s.db.WithContext(ctx).Table("projects AS p"). Select("p.id, p.name, p.id AS project_id, p.identifier AS project_identifier"). Where("p.workspace_id = ? AND p.deleted_at IS NULL AND (p.name ILIKE ? OR p.identifier ILIKE ?)", workspaceID, pat, pat). + Where(visClause, visArgs...). Order("p.name ASC").Limit(limit).Scan(&res.Projects).Error; err != nil { return res, err }