Sharing backend: shares CRUD + ACL enforcement everywhere (#16) #35
@@ -71,6 +71,9 @@ const (
|
||||
|
||||
RoleViewer = "viewer"
|
||||
RoleEditor = "editor"
|
||||
// RoleOwner is not a garden_shares row (ownership is implicit via
|
||||
// gardens.owner_id); it's the value Garden.MyRole carries for an owner.
|
||||
RoleOwner = "owner"
|
||||
|
||||
KindBed = "bed"
|
||||
KindGrowBag = "grow_bag"
|
||||
|
||||
@@ -36,7 +36,7 @@ const (
|
||||
func (r gardenRole) String() string {
|
||||
switch r {
|
||||
case roleOwner:
|
||||
return "owner"
|
||||
return domain.RoleOwner
|
||||
|
|
||||
case roleEditor:
|
||||
return domain.RoleEditor
|
||||
case roleViewer:
|
||||
|
||||
@@ -121,17 +121,19 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
originalPlantID := pl.PlantID
|
||||
applyPlantingPatch(pl, patch)
|
||||
// The plant must be visible to the actor only when they're CHANGING it — an
|
||||
// existing plop may reference a plant the actor can't see (e.g. a shared
|
||||
// editor working in the owner's garden with the owner's private plant), and
|
||||
// editing its radius/label/date must still work.
|
||||
if patch.PlantID != nil {
|
||||
if _, err := s.visiblePlant(ctx, actorID, pl.PlantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fetch the plop's plant for the derived count. Only when the actor is
|
||||
// actually CHANGING the plant (new id ≠ old) is the new plant gated on
|
||||
// visibility — an existing plop may reference a plant the actor can't see
|
||||
// (e.g. a shared editor in the owner's garden using the owner's private
|
||||
|
gitea-actions
commented
🟠 Plant-visibility re-check triggers on patch field presence, not actual value change, defeating the shared-editor fix for a no-op plantId resend error-handling, maintainability · flagged by 1 model
🪰 Gadfly · advisory 🟠 **Plant-visibility re-check triggers on patch field presence, not actual value change, defeating the shared-editor fix for a no-op plantId resend**
_error-handling, maintainability · flagged by 1 model_
- **`internal/service/plantings.go:129-133`** — The "only re-check plant visibility when the plant is *changed*" guard checks whether `patch.PlantID` is *present*, not whether the value actually *differs* from the planting's current `PlantID`: ```go if patch.PlantID != nil { if _, err := s.visiblePlant(ctx, actorID, pl.PlantID); err != nil { return nil, err } } ``` If a caller sends `plantId` in the PATCH body equal to the plop's existing plant (e.g. a user re-opens a plant picker and re-selects…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// plant), and a no-op plantId resend must not break editing it.
|
||||
var plant *domain.Plant
|
||||
if pl.PlantID != originalPlantID {
|
||||
plant, err = s.visiblePlant(ctx, actorID, pl.PlantID)
|
||||
} else {
|
||||
|
gitea-actions
commented
🟡 Redundant duplicate GetPlant query in UpdatePlanting when the plant is changed error-handling, performance · flagged by 2 models
🪰 Gadfly · advisory 🟡 **Redundant duplicate GetPlant query in UpdatePlanting when the plant is changed**
_error-handling, performance · flagged by 2 models_
- `internal/service/plantings.go:134` — **`GetPlant` after a plant-id change can return `ErrNotFound` and surface as a raw 404 where `ErrInvalidInput` (400) was intended.** When `patch.PlantID != nil`, the code first calls `s.visiblePlant(...)` which maps a missing/invisible plant to `ErrInvalidInput` (line 130, confirmed at `plantings.go:176-187`). But `visiblePlant` already fetches the plant and returns it — that value is discarded (`if _, err := ...`), and then line 134 calls `s.store.GetPlan…
<sub>🪰 Gadfly · advisory</sub>
|
||||
plant, err = s.store.GetPlant(ctx, pl.PlantID)
|
||||
}
|
||||
plant, err := s.store.GetPlant(ctx, pl.PlantID) // for the derived count
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -61,15 +61,17 @@ func (s *Service) UpdateShareRole(ctx context.Context, actorID, gardenID, target
|
||||
}
|
||||
|
||||
// RemoveShare revokes a share. The garden owner may remove anyone; a recipient
|
||||
// may remove themselves ("leave garden"). Any other actor gets the garden's
|
||||
// masked ErrNotFound (existence isn't revealed to non-participants).
|
||||
// may remove themselves ("leave garden"). It routes through requireGardenRole
|
||||
// (roleViewer) so a non-participant gets the standard masked ErrNotFound, then
|
||||
// applies the owner-or-self rule on top (a participant removing someone else's
|
||||
|
gitea-actions
commented
🔴 RemoveShare bypasses requireGardenRole authorization choke point maintainability, security · flagged by 5 models
🪰 Gadfly · advisory 🔴 **RemoveShare bypasses requireGardenRole authorization choke point**
_maintainability, security · flagged by 5 models_
- **`RemoveShare` bypasses the `requireGardenRole` authorization choke point**, allowing an actor with no role on a garden to reach `store.DeleteShare`. `internal/service/shares.go:66` opens the garden with a raw `store.GetGarden` instead of funneling through `requireGardenRole` — the comment on `requireGardenRole` calls it “THE place authorization for a garden is decided.” A stranger can call `RemoveShare` with `targetUserID == actorID`; the ad-hoc check `g.OwnerID != actorID && actorID != targ…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// share is ErrForbidden — they can already see the garden).
|
||||
func (s *Service) RemoveShare(ctx context.Context, actorID, gardenID, targetUserID int64) error {
|
||||
g, err := s.store.GetGarden(ctx, gardenID)
|
||||
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||
if err != nil {
|
||||
return err // ErrNotFound
|
||||
return err // ErrNotFound for a non-participant
|
||||
|
gitea-actions
commented
🟡 Owner removing a non-existent share returns ErrNotFound (404) instead of 204; behavior is defensible but undocumented beyond a comment error-handling, maintainability, security · flagged by 2 models
🪰 Gadfly · advisory 🟡 **Owner removing a non-existent share returns ErrNotFound (404) instead of 204; behavior is defensible but undocumented beyond a comment**
_error-handling, maintainability, security · flagged by 2 models_
- `internal/service/shares.go:71` — **Owner removing a non-existent share / self-leave of a non-share returns `ErrNotFound` instead of 204.** In `RemoveShare`, the guard is `g.OwnerID != actorID && actorID != targetUserID` (confirmed). The `GetGarden` lookup at line 67 masks the garden from strangers (good). But the owner path and the self-leave path both fall through to `s.store.DeleteShare` (line 76), which returns `domain.ErrNotFound` when 0 rows are affected (confirmed at `internal/store/sha…
<sub>🪰 Gadfly · advisory</sub>
|
||||
}
|
||||
if g.OwnerID != actorID && actorID != targetUserID {
|
||||
return domain.ErrNotFound
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
// Owner path removes any share; self-leave removes the actor's own (a missing
|
||||
// row is ErrNotFound either way).
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestListGardensIncludesSharedWithRole(t *testing.T) {
|
||||
|
||||
// Owner sees it as "owner".
|
||||
own, _ := s.ListGardens(ctx, owner)
|
||||
if len(own) != 1 || own[0].MyRole != "owner" {
|
||||
if len(own) != 1 || own[0].MyRole != domain.RoleOwner {
|
||||
t.Fatalf("owner list = %+v, want one garden with myRole owner", own)
|
||||
}
|
||||
// other sees nothing yet.
|
||||
|
||||
@@ -40,6 +40,10 @@ func (d *DB) GetShareRole(ctx context.Context, gardenID, userID int64) (role str
|
||||
return role, true, nil
|
||||
}
|
||||
|
||||
// maxSharesListed bounds a garden's share list defensively (a garden is shared
|
||||
// with a handful of people at household scale; this is never hit).
|
||||
const maxSharesListed = 1000
|
||||
|
||||
|
gitea-actions
commented
🟠 ListSharesForGarden is unbounded — missing LIMIT clause performance · flagged by 1 model
🪰 Gadfly · advisory 🟠 **ListSharesForGarden is unbounded — missing LIMIT clause**
_performance · flagged by 1 model_
- **`internal/store/shares.go:46`** — `ListSharesForGarden` queries `garden_shares` joined with `users` but has no `LIMIT` clause, making the result set unbounded. `ListGardensForActor` (in the same PR area) explicitly caps at `maxGardensListed = 1000` as a "defensive backstop against a pathologically large result"; the shares list should have the same defense. Even at normal sharing scales this is unlikely to be hit, but the endpoint should not be the only unbounded list query in the surface. *…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// ListSharesForGarden returns a garden's shares joined with each recipient's
|
||||
// email and display name, for the sharing UI. Always a non-nil slice.
|
||||
func (d *DB) ListSharesForGarden(ctx context.Context, gardenID int64) ([]domain.ShareWithUser, error) {
|
||||
@@ -47,8 +51,9 @@ func (d *DB) ListSharesForGarden(ctx context.Context, gardenID int64) ([]domain.
|
||||
`SELECT `+qualifyColumns("s", shareColumns)+`, u.email, u.display_name
|
||||
FROM garden_shares s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.garden_id = ?
|
||||
ORDER BY u.display_name COLLATE NOCASE, s.id`,
|
||||
gardenID,
|
||||
ORDER BY u.display_name COLLATE NOCASE, s.id
|
||||
LIMIT ?`,
|
||||
gardenID, maxSharesListed,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list shares: %w", err)
|
||||
|
||||
🟡 "owner" role string duplicated between service String() and store SQL literal; no domain.RoleOwner constant
maintainability · flagged by 2 models
internal/service/gardens.go:39—"owner"role string is hardcoded ingardenRole.String()while the store SQL atinternal/store/gardens.go:78emits'owner' AS my_roleas a literal. There is nodomain.RoleOwnerconstant (onlyRoleViewer/RoleEditor). If either side drifts, owned-gardenMyRolesilently mismatches. Fix: adddomain.RoleOwner = "owner"and reference it in both places. -internal/store/shares.go:61—ListSharesForGardeninlines the share-column scan ins…🪰 Gadfly · advisory