Sharing backend: shares CRUD + ACL enforcement everywhere (#16) #35
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user