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
49 changes: 49 additions & 0 deletions apps/api/internal/service/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package service

import (
"context"

"github.com/google/uuid"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
)

// enforceProjectVisibility rejects access to a project whose visibility
// (`network`) is not public, unless the caller is a workspace admin/owner or a
// member of the project itself. Public projects are visible to any workspace
// member.
//
// It mirrors the gate in ProjectService.GetByID so that project sub-resources
// (issues, states, labels, cycles, modules, estimates, intake, comments, pages,
// views, attachments, …) respect the same rules — otherwise a plain workspace
// member could reach a secret project's data through its sub-resource routes
// even though the project itself returns 404.
//
// It returns ErrProjectNotFound when the project is hidden from the caller, to
// match GetByID's 404 behaviour and avoid leaking the project's existence.
// Callers should invoke it only after they have already confirmed workspace
// membership and that the project belongs to the workspace.
func enforceProjectVisibility(
ctx context.Context,
ps *store.ProjectStore,
ws *store.WorkspaceStore,
workspaceID, projectID, userID uuid.UUID,
) error {
p, err := ps.GetByID(ctx, projectID)
if err != nil {
return ErrProjectNotFound
}
if p.Network == model.NetworkPublic {
return nil
}
// Secret project: workspace admins/owners can always see it.
if wm, err := ws.GetMember(ctx, workspaceID, userID); err == nil && wm != nil && wm.Role >= model.RoleAdmin {
return nil
}
// Otherwise the caller must be a member of the project.
if pm, err := ps.GetProjectMember(ctx, projectID, userID); err == nil && pm != nil {
return nil
}
return ErrProjectNotFound
}
109 changes: 109 additions & 0 deletions apps/api/internal/service/access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package service_test

import (
"context"
"errors"
"testing"

"github.com/google/uuid"
"gorm.io/gorm"

"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"
)

func makeProjectSecret(t *testing.T, db *gorm.DB, projectID uuid.UUID) {
t.Helper()
if err := db.Model(&model.Project{}).Where("id = ?", projectID).
Update("network", model.NetworkSecret).Error; err != nil {
t.Fatalf("make project secret: %v", err)
}
}

// A secret (non-public) project's sub-resources must respect the same
// visibility rule as ProjectService.GetByID: reachable only by a workspace
// admin/owner or a member of the project — never by a plain workspace member
// who was never added to the project, even though workspace membership and
// project-in-workspace both pass. Exercised through real service methods so the
// gate is verified end-to-end for every sub-resource that shares
// ensureProjectAccess.
func TestSecretProjectVisibility(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) // workspace owner + auto project lead

admin := testutil.CreateUser(t, db)
testutil.AddWorkspaceMember(t, db, wrk.ID, admin.ID, model.RoleAdmin)

// Plain workspace member, not added to the project.
outsider := testutil.CreateUser(t, db)
testutil.AddWorkspaceMember(t, db, wrk.ID, outsider.ID, model.RoleMember)

// Plain workspace member who IS a member of the project.
insider := testutil.CreateUser(t, db)
testutil.AddWorkspaceMember(t, db, wrk.ID, insider.ID, model.RoleMember)

proj := testutil.CreateProject(t, db, wrk.ID, owner.ID)
testutil.AddProjectMember(t, db, proj.ID, wrk.ID, insider.ID, model.RoleMember)

intakeSvc := service.NewIntakeService(
store.NewIntakeStore(db),
store.NewIssueStore(db),
store.NewProjectStore(db),
store.NewWorkspaceStore(db),
)
stateSvc := service.NewStateService(
store.NewStateStore(db),
store.NewProjectStore(db),
store.NewWorkspaceStore(db),
)

// listErr returns the access error surfaced by two independent
// sub-resource services (intake and states), so the assertion isn't tied
// to one code path.
listErr := func(userID uuid.UUID) (error, error) {
_, e1 := intakeSvc.List(ctx, wrk.Slug, proj.ID, userID, nil)
_, e2 := stateSvc.List(ctx, wrk.Slug, proj.ID, userID)
return e1, e2
}

// While public, every workspace member can read the sub-resources.
if e1, e2 := listErr(outsider.ID); e1 != nil || e2 != nil {
t.Fatalf("public project should be readable by a workspace member: intake=%v state=%v", e1, e2)
}

makeProjectSecret(t, db, proj.ID)

t.Run("outsider-denied", func(t *testing.T) {
e1, e2 := listErr(outsider.ID)
if !errors.Is(e1, service.ErrProjectNotFound) {
t.Errorf("intake: want ErrProjectNotFound, got %v", e1)
}
if !errors.Is(e2, service.ErrProjectNotFound) {
t.Errorf("states: want ErrProjectNotFound, got %v", e2)
}
})

t.Run("workspace-admin-allowed", func(t *testing.T) {
if e1, e2 := listErr(admin.ID); e1 != nil || e2 != nil {
t.Errorf("workspace admin should see the secret project: intake=%v state=%v", e1, e2)
}
})

t.Run("project-member-allowed", func(t *testing.T) {
if e1, e2 := listErr(insider.ID); e1 != nil || e2 != nil {
t.Errorf("project member should see the secret project: intake=%v state=%v", e1, e2)
}
})

t.Run("project-lead-allowed", func(t *testing.T) {
if e1, e2 := listErr(owner.ID); e1 != nil || e2 != nil {
t.Errorf("project lead should see the secret project: intake=%v state=%v", e1, e2)
}
})
}
6 changes: 6 additions & 0 deletions apps/api/internal/service/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func (s *AttachmentService) ensureProjectAccess(ctx context.Context, workspaceSl
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down Expand Up @@ -118,6 +121,9 @@ func (s *AttachmentService) AuthorizeDownload(ctx context.Context, issueID, asse
if !ok {
return ErrProjectForbidden
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, att.WorkspaceID, att.ProjectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func (s *CommentService) ensureProjectAccess(ctx context.Context, workspaceSlug
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ func (s *CycleService) ensureProjectAccess(ctx context.Context, workspaceSlug st
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func (s *EstimateService) ensureProjectAccess(ctx context.Context, workspaceSlug
if !inWorkspace {
return nil, ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return nil, err
}
return wrk, nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func (s *ExportService) ExportIssues(ctx context.Context, slug string, userID uu
if !in {
return "", nil, ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, pid, userID); err != nil {
return "", nil, err
}
}

data, err := s.buildWorkbook(ctx, projectIDs)
Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/intake.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func (s *IntakeService) ensureProjectAccess(ctx context.Context, workspaceSlug s
if !inWorkspace {
return nil, ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return nil, err
}
return wrk, nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ func (s *IssueService) ensureProjectAccess(ctx context.Context, workspaceSlug st
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/issue_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func (s *IssueViewService) ensureProjectAccess(ctx context.Context, workspaceSlu
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ func (s *LabelService) ensureProjectAccess(ctx context.Context, workspaceSlug st
if !inWorkspace {
return uuid.Nil, ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return uuid.Nil, err
}
return wrk.ID, nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ func (s *ModuleService) ensureProjectAccess(ctx context.Context, workspaceSlug s
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ func (s *PageService) ensureProjectAccess(ctx context.Context, workspaceSlug str
if !inWorkspace {
return ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.projectStore, s.ws, wrk.ID, projectID, userID); err != nil {
return err
}
return nil
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/service/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func (s *StateService) ensureProjectAccess(ctx context.Context, workspaceSlug st
if !inWorkspace {
return uuid.Nil, ErrProjectNotFound
}
if err := enforceProjectVisibility(ctx, s.ps, s.ws, wrk.ID, projectID, userID); err != nil {
return uuid.Nil, err
}
return wrk.ID, nil
}

Expand Down
Loading