a6680b1c05
GET/PUT /acl/:object_type/:object_id performed no authorization check, so any authenticated user could read the permission list of, or grant themselves view/edit on, any file/tag/category/pool. ACLService now resolves the object's owner and rejects callers who are neither the owner nor an admin. SetPermissions also wraps its delete+insert replace in a single transaction so a partial failure can no longer wipe permissions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
4.5 KiB
Go
177 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"tanabata/backend/internal/domain"
|
|
"tanabata/backend/internal/port"
|
|
)
|
|
|
|
// ACLService handles access control checks and permission management.
|
|
type ACLService struct {
|
|
aclRepo port.ACLRepo
|
|
files port.FileRepo
|
|
tags port.TagRepo
|
|
categories port.CategoryRepo
|
|
pools port.PoolRepo
|
|
tx port.Transactor
|
|
}
|
|
|
|
// NewACLService creates an ACLService. The object repositories are used to
|
|
// resolve an object's owner when authorizing permission management.
|
|
func NewACLService(
|
|
aclRepo port.ACLRepo,
|
|
files port.FileRepo,
|
|
tags port.TagRepo,
|
|
categories port.CategoryRepo,
|
|
pools port.PoolRepo,
|
|
tx port.Transactor,
|
|
) *ACLService {
|
|
return &ACLService{
|
|
aclRepo: aclRepo,
|
|
files: files,
|
|
tags: tags,
|
|
categories: categories,
|
|
pools: pools,
|
|
tx: tx,
|
|
}
|
|
}
|
|
|
|
// CanView returns true if the user may view the object.
|
|
// isAdmin, creatorID, isPublic must be populated from the object record by the caller.
|
|
func (s *ACLService) CanView(
|
|
ctx context.Context,
|
|
userID int16, isAdmin bool,
|
|
creatorID int16, isPublic bool,
|
|
objectTypeID int16, objectID uuid.UUID,
|
|
) (bool, error) {
|
|
if isAdmin {
|
|
return true, nil
|
|
}
|
|
if isPublic {
|
|
return true, nil
|
|
}
|
|
if userID == creatorID {
|
|
return true, nil
|
|
}
|
|
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return perm.CanView, nil
|
|
}
|
|
|
|
// CanEdit returns true if the user may edit the object.
|
|
// is_public does not grant edit access; only admins, creators, and explicit grants.
|
|
func (s *ACLService) CanEdit(
|
|
ctx context.Context,
|
|
userID int16, isAdmin bool,
|
|
creatorID int16,
|
|
objectTypeID int16, objectID uuid.UUID,
|
|
) (bool, error) {
|
|
if isAdmin {
|
|
return true, nil
|
|
}
|
|
if userID == creatorID {
|
|
return true, nil
|
|
}
|
|
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return perm.CanEdit, nil
|
|
}
|
|
|
|
// GetPermissions returns all explicit ACL entries for an object. Only the
|
|
// object's owner or an admin may inspect its permission list.
|
|
func (s *ACLService) GetPermissions(
|
|
ctx context.Context,
|
|
userID int16, isAdmin bool,
|
|
objectTypeID int16, objectID uuid.UUID,
|
|
) ([]domain.Permission, error) {
|
|
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.aclRepo.List(ctx, objectTypeID, objectID)
|
|
}
|
|
|
|
// SetPermissions replaces all ACL entries for an object (full replace semantics).
|
|
// Only the object's owner or an admin may change its permissions. The replace is
|
|
// performed atomically inside a single transaction.
|
|
func (s *ACLService) SetPermissions(
|
|
ctx context.Context,
|
|
userID int16, isAdmin bool,
|
|
objectTypeID int16, objectID uuid.UUID,
|
|
perms []domain.Permission,
|
|
) error {
|
|
if err := s.authorizeManage(ctx, userID, isAdmin, objectTypeID, objectID); err != nil {
|
|
return err
|
|
}
|
|
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
|
|
})
|
|
}
|
|
|
|
// authorizeManage returns nil if the user may manage the object's ACL
|
|
// (admin or owner), ErrForbidden otherwise, or a propagated lookup error
|
|
// (including ErrNotFound when the object does not exist).
|
|
func (s *ACLService) authorizeManage(
|
|
ctx context.Context,
|
|
userID int16, isAdmin bool,
|
|
objectTypeID int16, objectID uuid.UUID,
|
|
) error {
|
|
if isAdmin {
|
|
return nil
|
|
}
|
|
owner, err := s.objectOwner(ctx, objectTypeID, objectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner != userID {
|
|
return domain.ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// objectOwner resolves the creator ID of the object identified by
|
|
// (objectTypeID, objectID). Returns ErrNotFound if the object does not exist.
|
|
func (s *ACLService) objectOwner(ctx context.Context, objectTypeID int16, objectID uuid.UUID) (int16, error) {
|
|
switch objectTypeID {
|
|
case fileObjectTypeID:
|
|
obj, err := s.files.GetByID(ctx, objectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return obj.CreatorID, nil
|
|
case tagObjectTypeID:
|
|
obj, err := s.tags.GetByID(ctx, objectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return obj.CreatorID, nil
|
|
case categoryObjectTypeID:
|
|
obj, err := s.categories.GetByID(ctx, objectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return obj.CreatorID, nil
|
|
case poolObjectTypeID:
|
|
obj, err := s.pools.GetByID(ctx, objectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return obj.CreatorID, nil
|
|
default:
|
|
return 0, domain.ErrValidation
|
|
}
|
|
}
|