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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user