Sharing backend: shares CRUD + ACL enforcement everywhere (#16)
Build image / build-and-push (push) Successful in 4s
Build image / build-and-push (push) Successful in 4s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #35.
This commit is contained in:
+57
-14
@@ -32,6 +32,20 @@ const (
|
||||
roleOwner
|
||||
)
|
||||
|
||||
// String renders a role for the API's Garden.MyRole ("owner"/"editor"/"viewer").
|
||||
func (r gardenRole) String() string {
|
||||
switch r {
|
||||
case roleOwner:
|
||||
return domain.RoleOwner
|
||||
case roleEditor:
|
||||
return domain.RoleEditor
|
||||
case roleViewer:
|
||||
return domain.RoleViewer
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GardenInput is the mutable field set for creating or updating a garden.
|
||||
type GardenInput struct {
|
||||
Name string
|
||||
@@ -54,23 +68,41 @@ func (s *Service) requireGardenRole(ctx context.Context, actorID, gardenID int64
|
||||
if err != nil {
|
||||
return nil, err // ErrNotFound or a real error
|
||||
}
|
||||
role := effectiveGardenRole(actorID, g)
|
||||
role, err := s.effectiveGardenRole(ctx, actorID, g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == roleNone {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if role < min {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
g.MyRole = role.String() // so every read-through carries the actor's role
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// effectiveGardenRole is the actor's role on a garden. Owner is implicit via
|
||||
// gardens.owner_id; share-based viewer/editor roles are added in #16.
|
||||
func effectiveGardenRole(actorID int64, g *domain.Garden) gardenRole {
|
||||
// effectiveGardenRole is the actor's role on a garden: owner (implicit via
|
||||
// gardens.owner_id), else a viewer/editor grant from garden_shares, else none.
|
||||
func (s *Service) effectiveGardenRole(ctx context.Context, actorID int64, g *domain.Garden) (gardenRole, error) {
|
||||
if g.OwnerID == actorID {
|
||||
return roleOwner
|
||||
return roleOwner, nil
|
||||
}
|
||||
role, found, err := s.store.GetShareRole(ctx, g.ID, actorID)
|
||||
if err != nil {
|
||||
return roleNone, err
|
||||
}
|
||||
if !found {
|
||||
return roleNone, nil
|
||||
}
|
||||
switch role {
|
||||
case domain.RoleEditor:
|
||||
return roleEditor, nil
|
||||
case domain.RoleViewer:
|
||||
return roleViewer, nil
|
||||
default:
|
||||
return roleNone, nil
|
||||
}
|
||||
return roleNone
|
||||
}
|
||||
|
||||
// CreateGarden creates a garden owned by the actor. Missing dimensions default
|
||||
@@ -81,7 +113,12 @@ func (s *Service) CreateGarden(ctx context.Context, actorID int64, in GardenInpu
|
||||
return nil, err
|
||||
}
|
||||
g.OwnerID = actorID
|
||||
return s.store.CreateGarden(ctx, g)
|
||||
created, err := s.store.CreateGarden(ctx, g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created.MyRole = roleOwner.String() // the creator owns it
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// GetGarden returns a garden the actor may at least view.
|
||||
@@ -89,16 +126,18 @@ func (s *Service) GetGarden(ctx context.Context, actorID, gardenID int64) (*doma
|
||||
return s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||
}
|
||||
|
||||
// ListGardens returns the gardens the actor can see. Owned-only until #16.
|
||||
// ListGardens returns the gardens the actor can see: owned plus shared-with-them,
|
||||
// each row carrying the actor's my_role.
|
||||
func (s *Service) ListGardens(ctx context.Context, actorID int64) ([]domain.Garden, error) {
|
||||
return s.store.ListGardensForOwner(ctx, actorID)
|
||||
return s.store.ListGardensForActor(ctx, actorID)
|
||||
}
|
||||
|
||||
// UpdateGarden applies a version-guarded update; the actor must be at least an
|
||||
// editor. On a version mismatch it returns (current garden, ErrVersionConflict)
|
||||
// so the handler can return the fresh row for the client to rebase.
|
||||
// UpdateGarden applies a version-guarded update to a garden's metadata; only the
|
||||
// OWNER may edit metadata (editors edit contents, not the garden itself). On a
|
||||
// version mismatch it returns (current garden, ErrVersionConflict) so the handler
|
||||
// can return the fresh row for the client to rebase.
|
||||
func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in GardenInput, version int64) (*domain.Garden, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, err := gardenFromInput(in, false) // no defaults: an update states every field
|
||||
@@ -107,7 +146,11 @@ func (s *Service) UpdateGarden(ctx context.Context, actorID, gardenID int64, in
|
||||
}
|
||||
g.ID = gardenID
|
||||
g.Version = version
|
||||
return s.store.UpdateGarden(ctx, g)
|
||||
updated, err := s.store.UpdateGarden(ctx, g)
|
||||
if updated != nil {
|
||||
updated.MyRole = roleOwner.String() // only the owner reaches here
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
// DeleteGarden removes a garden; only the owner may.
|
||||
|
||||
@@ -121,9 +121,19 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
originalPlantID := pl.PlantID
|
||||
applyPlantingPatch(pl, patch)
|
||||
// The plant may have changed; the (possibly new) plant must be visible.
|
||||
plant, err := s.visiblePlant(ctx, actorID, pl.PlantID)
|
||||
// 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
|
||||
// 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 {
|
||||
plant, err = s.store.GetPlant(ctx, pl.PlantID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// isShareRole reports whether role is a grantable share role (owner is implicit,
|
||||
// never a share row).
|
||||
func isShareRole(role string) bool {
|
||||
return role == domain.RoleViewer || role == domain.RoleEditor
|
||||
}
|
||||
|
||||
// ListShares returns a garden's shares (each with the recipient's identity).
|
||||
// Owner only — sharing is managed by the owner alone.
|
||||
func (s *Service) ListShares(ctx context.Context, actorID, gardenID int64) ([]domain.ShareWithUser, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListSharesForGarden(ctx, gardenID)
|
||||
}
|
||||
|
||||
// AddShare grants a user viewer/editor access to a garden, targeting them by the
|
||||
// exact email of an existing account (v1 has no invitation emails). Owner only.
|
||||
// Unknown email → ErrShareUserNotFound; the owner's own email →
|
||||
// ErrCannotShareWithSelf; an already-shared user → ErrShareExists.
|
||||
func (s *Service) AddShare(ctx context.Context, actorID, gardenID int64, email, role string) (*domain.GardenShare, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isShareRole(role) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
target, err := s.store.GetUserByEmail(ctx, strings.TrimSpace(email))
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil, domain.ErrShareUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target.ID == actorID {
|
||||
return nil, domain.ErrCannotShareWithSelf
|
||||
}
|
||||
return s.store.CreateShare(ctx, &domain.GardenShare{
|
||||
GardenID: gardenID, UserID: target.ID, Role: role, CreatedBy: actorID,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateShareRole changes an existing share's role. Owner only.
|
||||
func (s *Service) UpdateShareRole(ctx context.Context, actorID, gardenID, targetUserID int64, role string) (*domain.GardenShare, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isShareRole(role) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
return s.store.UpdateShareRole(ctx, gardenID, targetUserID, role)
|
||||
}
|
||||
|
||||
// RemoveShare revokes a share. The garden owner may remove anyone; a recipient
|
||||
// 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
|
||||
// share is ErrForbidden — they can already see the garden).
|
||||
func (s *Service) RemoveShare(ctx context.Context, actorID, gardenID, targetUserID int64) error {
|
||||
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||
if err != nil {
|
||||
return err // ErrNotFound for a non-participant
|
||||
}
|
||||
if g.OwnerID != actorID && actorID != targetUserID {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
// Owner path removes any share; self-leave removes the actor's own (a missing
|
||||
// row is ErrNotFound either way).
|
||||
return s.store.DeleteShare(ctx, gardenID, targetUserID)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// wantErr asserts err matches want (nil = expected success, else errors.Is).
|
||||
func wantErr(t *testing.T, name string, err, want error) {
|
||||
t.Helper()
|
||||
if want == nil {
|
||||
if err != nil {
|
||||
t.Errorf("%s: got %v, want success", name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("%s: got %v, want %v", name, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGardenACLMatrix is the authoritative {owner, editor, viewer, stranger} ×
|
||||
// {read, mutate object, mutate planting, edit garden, delete garden, manage
|
||||
// shares} table. Read the columns as: nil = allowed, ErrForbidden = visible but
|
||||
// insufficient, ErrNotFound = existence masked.
|
||||
func TestGardenACLMatrix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
editor := seedUser(t, s, "[email protected]")
|
||||
viewer := seedUser(t, s, "[email protected]")
|
||||
stranger := seedUser(t, s, "[email protected]")
|
||||
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("seed plop: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); err != nil {
|
||||
t.Fatalf("share editor: %v", err)
|
||||
}
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share viewer: %v", err)
|
||||
}
|
||||
|
||||
// read /full — viewer and up; stranger masked.
|
||||
for _, tc := range []struct {
|
||||
actor int64
|
||||
want error
|
||||
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
||||
_, err := s.GardenFull(ctx, tc.actor, g.ID)
|
||||
wantErr(t, "readFull", err, tc.want)
|
||||
}
|
||||
|
||||
// mutate object — editor and up. Failure actors fail the role check before any
|
||||
// version check, so a dummy version is fine; success actors need the live one.
|
||||
x := 300.0
|
||||
_, ev := s.UpdateObject(ctx, viewer, bed.ID, ObjectPatch{XCM: &x}, 1)
|
||||
wantErr(t, "mutObject/viewer", ev, domain.ErrForbidden)
|
||||
_, es := s.UpdateObject(ctx, stranger, bed.ID, ObjectPatch{XCM: &x}, 1)
|
||||
wantErr(t, "mutObject/stranger", es, domain.ErrNotFound)
|
||||
cur, _ := s.store.GetObject(ctx, bed.ID)
|
||||
_, ee := s.UpdateObject(ctx, editor, bed.ID, ObjectPatch{XCM: &x}, cur.Version)
|
||||
wantErr(t, "mutObject/editor", ee, nil)
|
||||
cur, _ = s.store.GetObject(ctx, bed.ID)
|
||||
_, eo := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{YCM: &x}, cur.Version)
|
||||
wantErr(t, "mutObject/owner", eo, nil)
|
||||
|
||||
// mutate planting — editor and up.
|
||||
nr := 12.0
|
||||
_, pv := s.UpdatePlanting(ctx, viewer, plop.ID, PlantingPatch{RadiusCM: &nr}, 1)
|
||||
wantErr(t, "mutPlop/viewer", pv, domain.ErrForbidden)
|
||||
_, ps := s.UpdatePlanting(ctx, stranger, plop.ID, PlantingPatch{RadiusCM: &nr}, 1)
|
||||
wantErr(t, "mutPlop/stranger", ps, domain.ErrNotFound)
|
||||
curp, _ := s.store.GetPlanting(ctx, plop.ID)
|
||||
_, pe := s.UpdatePlanting(ctx, editor, plop.ID, PlantingPatch{RadiusCM: &nr}, curp.Version)
|
||||
wantErr(t, "mutPlop/editor", pe, nil)
|
||||
|
||||
// edit garden meta — OWNER only (editors edit contents, not the garden).
|
||||
edit := func(actor, version int64) error {
|
||||
_, err := s.UpdateGarden(ctx, actor, g.ID, GardenInput{Name: "Renamed", WidthCM: 1000, HeightCM: 1000, UnitPref: "metric"}, version)
|
||||
return err
|
||||
}
|
||||
wantErr(t, "editGarden/editor", edit(editor, 1), domain.ErrForbidden)
|
||||
wantErr(t, "editGarden/viewer", edit(viewer, 1), domain.ErrForbidden)
|
||||
wantErr(t, "editGarden/stranger", edit(stranger, 1), domain.ErrNotFound)
|
||||
curg, _ := s.GetGarden(ctx, owner, g.ID)
|
||||
wantErr(t, "editGarden/owner", edit(owner, curg.Version), nil)
|
||||
|
||||
// manage shares (list) — OWNER only.
|
||||
for _, tc := range []struct {
|
||||
actor int64
|
||||
want error
|
||||
}{{editor, domain.ErrForbidden}, {viewer, domain.ErrForbidden}, {stranger, domain.ErrNotFound}, {owner, nil}} {
|
||||
_, err := s.ListShares(ctx, tc.actor, g.ID)
|
||||
wantErr(t, "listShares", err, tc.want)
|
||||
}
|
||||
|
||||
// delete garden — OWNER only. Non-owners are refused (garden survives).
|
||||
wantErr(t, "delGarden/editor", s.DeleteGarden(ctx, editor, g.ID), domain.ErrForbidden)
|
||||
wantErr(t, "delGarden/viewer", s.DeleteGarden(ctx, viewer, g.ID), domain.ErrForbidden)
|
||||
wantErr(t, "delGarden/stranger", s.DeleteGarden(ctx, stranger, g.ID), domain.ErrNotFound)
|
||||
wantErr(t, "delGarden/owner", s.DeleteGarden(ctx, owner, g.ID), nil)
|
||||
}
|
||||
|
||||
func TestListGardensIncludesSharedWithRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
other := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
|
||||
// Owner sees it as "owner".
|
||||
own, _ := s.ListGardens(ctx, 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.
|
||||
if list, _ := s.ListGardens(ctx, other); len(list) != 0 {
|
||||
t.Fatalf("other list = %d, want 0 before sharing", len(list))
|
||||
}
|
||||
// Share as viewer → other sees it with myRole viewer.
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
list, _ := s.ListGardens(ctx, other)
|
||||
if len(list) != 1 || list[0].MyRole != domain.RoleViewer || list[0].ID != g.ID {
|
||||
t.Fatalf("other list after share = %+v, want the garden with myRole viewer", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddShareErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
|
||||
// Unknown email → ErrShareUserNotFound.
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); !errors.Is(err, domain.ErrShareUserNotFound) {
|
||||
t.Errorf("unknown email err = %v, want ErrShareUserNotFound", err)
|
||||
}
|
||||
// Self → ErrCannotShareWithSelf.
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); !errors.Is(err, domain.ErrCannotShareWithSelf) {
|
||||
t.Errorf("self-share err = %v, want ErrCannotShareWithSelf", err)
|
||||
}
|
||||
// Bad role → ErrInvalidInput.
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", "admin"); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("bad role err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
// First share ok; a second to the same user → ErrShareExists.
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("first share: %v", err)
|
||||
}
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); !errors.Is(err, domain.ErrShareExists) {
|
||||
t.Errorf("double-share err = %v, want ErrShareExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAndRemoveShare(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
friend := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
|
||||
// Upgrade to editor.
|
||||
up, err := s.UpdateShareRole(ctx, owner, g.ID, friend, domain.RoleEditor)
|
||||
if err != nil || up.Role != domain.RoleEditor {
|
||||
t.Fatalf("upgrade role = %+v, %v", up, err)
|
||||
}
|
||||
// The recipient can now mutate but still can't manage shares.
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
nx := 400.0
|
||||
cur, _ := s.store.GetObject(ctx, bed.ID)
|
||||
if _, err := s.UpdateObject(ctx, friend, bed.ID, ObjectPatch{XCM: &nx}, cur.Version); err != nil {
|
||||
t.Errorf("editor should move an object: %v", err)
|
||||
}
|
||||
if _, err := s.ListShares(ctx, friend, g.ID); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("editor listing shares = %v, want ErrForbidden", err)
|
||||
}
|
||||
|
||||
// A recipient can remove themselves ("leave garden").
|
||||
if err := s.RemoveShare(ctx, friend, g.ID, friend); err != nil {
|
||||
t.Errorf("self-leave: %v", err)
|
||||
}
|
||||
// After leaving, the garden is invisible again.
|
||||
if _, err := s.GardenFull(ctx, friend, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
||||
}
|
||||
// A non-participant can't remove someone else's share.
|
||||
stranger := seedUser(t, s, "[email protected]")
|
||||
if err := s.RemoveShare(ctx, stranger, g.ID, owner); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("stranger remove = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user