Sharing backend: shares CRUD + ACL enforcement everywhere (#16) #35

Merged
steve merged 2 commits from phase-6-sharing-api into main 2026-07-19 03:49:55 +00:00
6 changed files with 30 additions and 18 deletions
Showing only changes of commit 873c591635 - Show all commits
+3
View File
@@ -71,6 +71,9 @@ const (
RoleViewer = "viewer" RoleViewer = "viewer"
RoleEditor = "editor" 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" KindBed = "bed"
KindGrowBag = "grow_bag" KindGrowBag = "grow_bag"
+1 -1
View File
@@ -36,7 +36,7 @@ const (
func (r gardenRole) String() string { func (r gardenRole) String() string {
switch r { switch r {
case roleOwner: case roleOwner:
return "owner" return domain.RoleOwner
case roleEditor: case roleEditor:
return domain.RoleEditor return domain.RoleEditor
case roleViewer: case roleViewer:
1
+11 -9
View File
@@ -121,17 +121,19 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
return nil, err return nil, err
} }
originalPlantID := pl.PlantID
applyPlantingPatch(pl, patch) applyPlantingPatch(pl, patch)
// The plant must be visible to the actor only when they're CHANGING it — an // Fetch the plop's plant for the derived count. Only when the actor is
// existing plop may reference a plant the actor can't see (e.g. a shared // actually CHANGING the plant (new id ≠ old) is the new plant gated on
// editor working in the owner's garden with the owner's private plant), and // visibility — an existing plop may reference a plant the actor can't see
// editing its radius/label/date must still work. // (e.g. a shared editor in the owner's garden using the owner's private
if patch.PlantID != nil { // plant), and a no-op plantId resend must not break editing it.
if _, err := s.visiblePlant(ctx, actorID, pl.PlantID); err != nil { var plant *domain.Plant
return nil, err if pl.PlantID != originalPlantID {
plant, err = s.visiblePlant(ctx, actorID, pl.PlantID)
} else {
plant, err = s.store.GetPlant(ctx, pl.PlantID)
} }
}
plant, err := s.store.GetPlant(ctx, pl.PlantID) // for the derived count
if err != nil { if err != nil {
return nil, err return nil, err
} }
+7 -5
View File
2
@@ -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 // RemoveShare revokes a share. The garden owner may remove anyone; a recipient
// may remove themselves ("leave garden"). Any other actor gets the garden's // may remove themselves ("leave garden"). It routes through requireGardenRole
// masked ErrNotFound (existence isn't revealed to non-participants). // (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
// share is ErrForbidden — they can already see the garden).
func (s *Service) RemoveShare(ctx context.Context, actorID, gardenID, targetUserID int64) error { 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 { if err != nil {
return err // ErrNotFound return err // ErrNotFound for a non-participant
} }
if g.OwnerID != actorID && actorID != targetUserID { 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 // Owner path removes any share; self-leave removes the actor's own (a missing
// row is ErrNotFound either way). // row is ErrNotFound either way).
+1 -1
View File
1
@@ -118,7 +118,7 @@ func TestListGardensIncludesSharedWithRole(t *testing.T) {
// Owner sees it as "owner". // Owner sees it as "owner".
own, _ := s.ListGardens(ctx, 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) t.Fatalf("owner list = %+v, want one garden with myRole owner", own)
} }
// other sees nothing yet. // other sees nothing yet.
+7 -2
View File
@@ -40,6 +40,10 @@ func (d *DB) GetShareRole(ctx context.Context, gardenID, userID int64) (role str
return role, true, nil 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
// ListSharesForGarden returns a garden's shares joined with each recipient's // ListSharesForGarden returns a garden's shares joined with each recipient's
// email and display name, for the sharing UI. Always a non-nil slice. // 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) { 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 `SELECT `+qualifyColumns("s", shareColumns)+`, u.email, u.display_name
FROM garden_shares s JOIN users u ON u.id = s.user_id FROM garden_shares s JOIN users u ON u.id = s.user_id
WHERE s.garden_id = ? WHERE s.garden_id = ?
ORDER BY u.display_name COLLATE NOCASE, s.id`, ORDER BY u.display_name COLLATE NOCASE, s.id
gardenID, LIMIT ?`,
gardenID, maxSharesListed,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list shares: %w", err) return nil, fmt.Errorf("store: list shares: %w", err)
1