Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/internal/service/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
55 changes: 55 additions & 0 deletions apps/api/internal/service/search_visibility_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
23 changes: 20 additions & 3 deletions apps/api/internal/store/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -63,17 +75,19 @@ 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 {
q := s.db.WithContext(ctx).Table("issues AS i").
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)
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading