Skip to content

Commit 7477666

Browse files
Stefan ErnstStefan Ernst
authored andcommitted
Added better permission checks to item linking, attachment settings
1 parent 8d5a8a7 commit 7477666

11 files changed

Lines changed: 310 additions & 89 deletions

File tree

internal/handlers/base.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ package handlers
33
import (
44
"database/sql"
55
"errors"
6+
"fmt"
7+
"log/slog"
68
"net/http"
9+
"strings"
710
"windshift/internal/database"
11+
"windshift/internal/middleware"
812
"windshift/internal/models"
913
"windshift/internal/services"
1014
"windshift/internal/utils"
@@ -164,3 +168,95 @@ func AuthorizeUserRequest(w http.ResponseWriter, r *http.Request, targetUserID i
164168

165169
return currentUser
166170
}
171+
172+
// CheckItemPermission verifies the user has the given permission on the item's workspace.
173+
// Returns 404 on both not-found and no-permission to prevent item existence leakage.
174+
func CheckItemPermission(w http.ResponseWriter, r *http.Request, db database.Database,
175+
permService *services.PermissionService, itemID int, permission string) bool {
176+
user, ok := r.Context().Value(middleware.ContextKeyUser).(*models.User)
177+
if !ok {
178+
respondUnauthorized(w, r)
179+
return false
180+
}
181+
var workspaceID int
182+
err := db.QueryRow("SELECT workspace_id FROM items WHERE id = ?", itemID).Scan(&workspaceID)
183+
if err != nil {
184+
respondNotFound(w, r, "Item")
185+
return false
186+
}
187+
hasPermission, err := permService.HasWorkspacePermission(user.ID, workspaceID, permission)
188+
if err != nil || !hasPermission {
189+
respondNotFound(w, r, "Item") // 404, not 403 — prevents existence leakage
190+
return false
191+
}
192+
return true
193+
}
194+
195+
// GetAccessibleWorkspaceIDs returns IDs of active workspaces the user can view.
196+
func GetAccessibleWorkspaceIDs(user *models.User, db database.Database,
197+
permService *services.PermissionService) ([]int, error) {
198+
if user == nil || permService == nil {
199+
return []int{}, nil
200+
}
201+
rows, err := db.Query("SELECT id FROM workspaces WHERE active = 1")
202+
if err != nil {
203+
return nil, fmt.Errorf("failed to query workspaces: %w", err)
204+
}
205+
defer rows.Close()
206+
var ids []int
207+
for rows.Next() {
208+
var id int
209+
if err := rows.Scan(&id); err != nil {
210+
continue
211+
}
212+
hasView, err := permService.HasWorkspacePermission(user.ID, id, models.PermissionItemView)
213+
if err != nil {
214+
slog.Error("error checking view permission", slog.Int("workspace_id", id), slog.Any("error", err))
215+
continue
216+
}
217+
if hasView {
218+
ids = append(ids, id)
219+
}
220+
}
221+
return ids, rows.Err()
222+
}
223+
224+
// GetAccessibleWorkspaceKeys returns a set of workspace keys the user can view.
225+
func GetAccessibleWorkspaceKeys(user *models.User, db database.Database,
226+
permService *services.PermissionService) (map[string]bool, error) {
227+
if user == nil || permService == nil {
228+
return map[string]bool{}, nil
229+
}
230+
rows, err := db.Query("SELECT id, key FROM workspaces WHERE active = 1")
231+
if err != nil {
232+
return nil, fmt.Errorf("failed to query workspaces: %w", err)
233+
}
234+
defer rows.Close()
235+
keys := make(map[string]bool)
236+
for rows.Next() {
237+
var id int
238+
var key string
239+
if err := rows.Scan(&id, &key); err != nil {
240+
continue
241+
}
242+
hasView, err := permService.HasWorkspacePermission(user.ID, id, models.PermissionItemView)
243+
if err != nil {
244+
continue
245+
}
246+
if hasView {
247+
keys[key] = true
248+
}
249+
}
250+
return keys, rows.Err()
251+
}
252+
253+
// BuildWorkspaceIDPlaceholders builds a parameterized IN clause for workspace IDs.
254+
func BuildWorkspaceIDPlaceholders(ids []int) (string, []interface{}) {
255+
placeholders := make([]string, len(ids))
256+
args := make([]interface{}, len(ids))
257+
for i, id := range ids {
258+
placeholders[i] = "?"
259+
args[i] = id
260+
}
261+
return strings.Join(placeholders, ", "), args
262+
}

internal/handlers/diagram.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,26 @@ import (
1111
"windshift/internal/database"
1212
"windshift/internal/middleware"
1313
"windshift/internal/models"
14+
"windshift/internal/services"
1415
)
1516

1617
type DiagramHandler struct {
17-
db database.Database
18+
db database.Database
19+
permissionService *services.PermissionService
1820
}
1921

20-
func NewDiagramHandler(db database.Database) *DiagramHandler {
22+
func NewDiagramHandler(db database.Database, permissionService *services.PermissionService) *DiagramHandler {
2123
return &DiagramHandler{
22-
db: db,
24+
db: db,
25+
permissionService: permissionService,
2326
}
2427
}
2528

29+
// checkItemEditPermission checks if the current user can edit the given item
30+
func (h *DiagramHandler) checkItemEditPermission(w http.ResponseWriter, r *http.Request, itemID int) bool {
31+
return CheckItemPermission(w, r, h.db, h.permissionService, itemID, models.PermissionItemEdit)
32+
}
33+
2634
// Create creates a new diagram for an item
2735
func (h *DiagramHandler) Create(w http.ResponseWriter, r *http.Request) {
2836
itemIDStr := r.PathValue("itemId")
@@ -32,6 +40,10 @@ func (h *DiagramHandler) Create(w http.ResponseWriter, r *http.Request) {
3240
return
3341
}
3442

43+
if !h.checkItemEditPermission(w, r, itemID) {
44+
return
45+
}
46+
3547
var req struct {
3648
Name string `json:"name"`
3749
DiagramData string `json:"diagram_data"`
@@ -271,6 +283,10 @@ func (h *DiagramHandler) Update(w http.ResponseWriter, r *http.Request) {
271283
return
272284
}
273285

286+
if !h.checkItemEditPermission(w, r, itemID) {
287+
return
288+
}
289+
274290
query := `
275291
UPDATE item_diagrams
276292
SET name = ?, diagram_data = ?, updated_at = ?, updated_by = ?
@@ -385,6 +401,10 @@ func (h *DiagramHandler) Delete(w http.ResponseWriter, r *http.Request) {
385401
return
386402
}
387403

404+
if !h.checkItemEditPermission(w, r, itemID) {
405+
return
406+
}
407+
388408
// Record history before deletion
389409
if userID != nil {
390410
if err := h.recordDiagramHistory(itemID, userID, "diagram_deleted", &diagramName, 0, diagramName); err != nil {

internal/handlers/item_links.go

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
type ItemLinkHandler struct {
1818
db database.Database
19+
permissionService *services.PermissionService
1920
notificationService interface {
2021
EmitEvent(event *services.NotificationEvent)
2122
} // Notification service for async notification processing (optional, can be nil)
@@ -26,13 +27,19 @@ type ItemLinkHandler struct {
2627

2728
func NewItemLinkHandler(db database.Database, notificationService interface {
2829
EmitEvent(event *services.NotificationEvent)
29-
}) *ItemLinkHandler {
30+
}, permissionService *services.PermissionService) *ItemLinkHandler {
3031
return &ItemLinkHandler{
3132
db: db,
3233
notificationService: notificationService,
34+
permissionService: permissionService,
3335
}
3436
}
3537

38+
// checkItemEditPermission checks if the current user can edit the given item
39+
func (h *ItemLinkHandler) checkItemEditPermission(w http.ResponseWriter, r *http.Request, itemID int) bool {
40+
return CheckItemPermission(w, r, h.db, h.permissionService, itemID, models.PermissionItemEdit)
41+
}
42+
3643
// SetActionService sets the action service for automation workflows
3744
func (h *ItemLinkHandler) SetActionService(actionService interface {
3845
EmitActionEvent(event *models.ActionEvent)
@@ -49,6 +56,23 @@ func (h *ItemLinkHandler) GetLinksForItem(w http.ResponseWriter, r *http.Request
4956
return
5057
}
5158

59+
user := h.getUserFromContext(r)
60+
if user == nil {
61+
respondUnauthorized(w, r)
62+
return
63+
}
64+
65+
// Check item.view permission if it's a work item
66+
var workspaceID int
67+
isWorkItem := h.db.QueryRow("SELECT workspace_id FROM items WHERE id = ?", id).Scan(&workspaceID) == nil
68+
if isWorkItem {
69+
hasView, _ := h.permissionService.HasWorkspacePermission(user.ID, workspaceID, models.PermissionItemView)
70+
if !hasView {
71+
respondNotFound(w, r, "item")
72+
return
73+
}
74+
}
75+
5276
// Convert URL path to internal type
5377
internalType := "item"
5478
if itemType == "test-cases" {
@@ -69,6 +93,11 @@ func (h *ItemLinkHandler) GetLinksForItem(w http.ResponseWriter, r *http.Request
6993
return
7094
}
7195

96+
// Filter linked items by accessible workspaces
97+
accessibleKeys, _ := GetAccessibleWorkspaceKeys(user, h.db, h.permissionService)
98+
outgoingLinks = filterLinksByAccessibleWorkspaces(outgoingLinks, accessibleKeys)
99+
incomingLinks = filterLinksByAccessibleWorkspaces(incomingLinks, accessibleKeys)
100+
72101
response := map[string]interface{}{
73102
"outgoing": outgoingLinks,
74103
"incoming": incomingLinks,
@@ -78,6 +107,21 @@ func (h *ItemLinkHandler) GetLinksForItem(w http.ResponseWriter, r *http.Request
78107
json.NewEncoder(w).Encode(response)
79108
}
80109

110+
// filterLinksByAccessibleWorkspaces removes links pointing to items in inaccessible workspaces
111+
func filterLinksByAccessibleWorkspaces(links []models.ItemLink, accessibleKeys map[string]bool) []models.ItemLink {
112+
filtered := make([]models.ItemLink, 0, len(links))
113+
for _, link := range links {
114+
if link.SourceType == "item" && link.SourceWorkspaceKey != "" && !accessibleKeys[link.SourceWorkspaceKey] {
115+
continue
116+
}
117+
if link.TargetType == "item" && link.TargetWorkspaceKey != "" && !accessibleKeys[link.TargetWorkspaceKey] {
118+
continue
119+
}
120+
filtered = append(filtered, link)
121+
}
122+
return filtered
123+
}
124+
81125
// CreateLink creates a new link between items
82126
func (h *ItemLinkHandler) CreateLink(w http.ResponseWriter, r *http.Request) {
83127
var link models.ItemLink
@@ -146,6 +190,19 @@ func (h *ItemLinkHandler) CreateLink(w http.ResponseWriter, r *http.Request) {
146190
}
147191
createdBy := currentUser.ID
148192

193+
// Check item.edit on source (authorizes modifying the source by adding a link)
194+
if link.SourceType == "item" {
195+
if !CheckItemPermission(w, r, h.db, h.permissionService, link.SourceID, models.PermissionItemEdit) {
196+
return
197+
}
198+
}
199+
// Check item.view on target (verifies user can see it — prevents existence leakage)
200+
if link.TargetType == "item" {
201+
if !CheckItemPermission(w, r, h.db, h.permissionService, link.TargetID, models.PermissionItemView) {
202+
return
203+
}
204+
}
205+
149206
// Create link via service (handles link type validation + insert)
150207
linkSvc := services.NewItemLinkService(h.db)
151208
id, err := linkSvc.CreateLink(services.CreateItemLinkParams{
@@ -270,6 +327,13 @@ func (h *ItemLinkHandler) DeleteLink(w http.ResponseWriter, r *http.Request) {
270327
return
271328
}
272329

330+
// Check item.edit permission for item-type source
331+
if sourceType == "item" {
332+
if !h.checkItemEditPermission(w, r, sourceID) {
333+
return
334+
}
335+
}
336+
273337
result, err := h.db.ExecWrite("DELETE FROM item_links WHERE id = ?", id)
274338
if err != nil {
275339
respondInternalError(w, r, err)
@@ -333,6 +397,10 @@ func (h *ItemLinkHandler) GetLinkedAssets(w http.ResponseWriter, r *http.Request
333397
return
334398
}
335399

400+
if !CheckItemPermission(w, r, h.db, h.permissionService, id, models.PermissionItemView) {
401+
return
402+
}
403+
336404
// Get assets where item is the source
337405
outgoingQuery := `
338406
SELECT a.id, a.title, COALESCE(a.description, '') AS description,
@@ -441,6 +509,18 @@ func (h *ItemLinkHandler) GetLinkedAssets(w http.ResponseWriter, r *http.Request
441509

442510
// SearchLinkableItems searches for items that can be linked
443511
func (h *ItemLinkHandler) SearchLinkableItems(w http.ResponseWriter, r *http.Request) {
512+
user := h.getUserFromContext(r)
513+
if user == nil {
514+
respondUnauthorized(w, r)
515+
return
516+
}
517+
518+
accessibleWorkspaceIDs, err := GetAccessibleWorkspaceIDs(user, h.db, h.permissionService)
519+
if err != nil {
520+
respondInternalError(w, r, err)
521+
return
522+
}
523+
444524
query := r.URL.Query().Get("q")
445525
itemType := r.URL.Query().Get("type") // "item", "test_case", "asset", or empty for all
446526
limit := 20
@@ -455,7 +535,7 @@ func (h *ItemLinkHandler) SearchLinkableItems(w http.ResponseWriter, r *http.Req
455535

456536
// Search work items
457537
if itemType == "" || itemType == "item" {
458-
workItems, err := h.searchWorkItems(query, limit)
538+
workItems, err := h.searchWorkItems(query, limit, accessibleWorkspaceIDs)
459539
if err != nil {
460540
respondInternalError(w, r, err)
461541
return
@@ -569,11 +649,16 @@ func (h *ItemLinkHandler) getLinkByID(id int) (*models.ItemLink, error) {
569649
return &links[0], nil
570650
}
571651

572-
func (h *ItemLinkHandler) searchWorkItems(query string, limit int) ([]models.LinkableItem, error) {
573-
sqlQuery := `
574-
SELECT
575-
i.id,
576-
i.title,
652+
func (h *ItemLinkHandler) searchWorkItems(query string, limit int, accessibleWorkspaceIDs []int) ([]models.LinkableItem, error) {
653+
if len(accessibleWorkspaceIDs) == 0 {
654+
return []models.LinkableItem{}, nil
655+
}
656+
657+
placeholders, wsArgs := BuildWorkspaceIDPlaceholders(accessibleWorkspaceIDs)
658+
sqlQuery := fmt.Sprintf(`
659+
SELECT
660+
i.id,
661+
i.title,
577662
COALESCE(i.description, '') AS description,
578663
i.workspace_id,
579664
w.name AS workspace_name,
@@ -583,13 +668,17 @@ func (h *ItemLinkHandler) searchWorkItems(query string, limit int) ([]models.Lin
583668
LEFT JOIN workspaces w ON i.workspace_id = w.id
584669
LEFT JOIN statuses s ON i.status_id = s.id
585670
LEFT JOIN priorities p ON i.priority_id = p.id
586-
WHERE i.title LIKE ? OR i.description LIKE ?
671+
WHERE (i.title LIKE ? OR i.description LIKE ?)
672+
AND i.workspace_id IN (%s)
587673
ORDER BY i.title
588674
LIMIT ?
589-
`
675+
`, placeholders)
590676

591677
searchTerm := "%" + query + "%"
592-
rows, err := h.db.Query(sqlQuery, searchTerm, searchTerm, limit)
678+
args := []interface{}{searchTerm, searchTerm}
679+
args = append(args, wsArgs...)
680+
args = append(args, limit)
681+
rows, err := h.db.Query(sqlQuery, args...)
593682
if err != nil {
594683
return nil, err
595684
}

0 commit comments

Comments
 (0)