feat(server): add project sharing access control
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const defaultVisitorLimit int64 = 100
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
codec *secureCodec
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store, encryptionSecret string) (*Service, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("%w: sharing store is required", ErrInvalidInput)
|
||||
}
|
||||
codec, err := newSecureCodec(encryptionSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{store: store, codec: codec, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
func ParseLinkPermission(value string) (Permission, error) {
|
||||
permission := Permission(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch permission {
|
||||
case PermissionPrivate, PermissionCanvas, PermissionViewer, PermissionEditor:
|
||||
return permission, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unsupported link permission", ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMemberPermission(value string) (Permission, error) {
|
||||
permission := Permission(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch permission {
|
||||
case PermissionCanvas, PermissionViewer, PermissionEditor:
|
||||
return permission, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unsupported member permission", ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Settings(ctx context.Context, projectID string, ownerID string) (Settings, error) {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
||||
}
|
||||
return Settings{}, err
|
||||
}
|
||||
if policy.OwnerID != ownerID {
|
||||
return Settings{}, ErrForbidden
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLink(ctx context.Context, projectID string, ownerID string, permission Permission) (Settings, error) {
|
||||
if permission == PermissionPrivate {
|
||||
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
||||
}
|
||||
return Settings{}, err
|
||||
}
|
||||
if policy.OwnerID != ownerID {
|
||||
return Settings{}, ErrForbidden
|
||||
}
|
||||
policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC())
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
policy, err := s.ensurePolicy(ctx, projectID, ownerID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC())
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
|
||||
func (s *Service) Invite(ctx context.Context, projectID string, owner Actor, identifier string, permission Permission) (Settings, error) {
|
||||
identifierType, normalized, err := normalizeIdentifier(identifier)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
owner.UserID = strings.TrimSpace(owner.UserID)
|
||||
if !owner.Authenticated || owner.UserID == "" {
|
||||
return Settings{}, ErrForbidden
|
||||
}
|
||||
if ActorOwnsIdentifier(owner, normalized) {
|
||||
return Settings{}, ErrSelfInvite
|
||||
}
|
||||
policy, err := s.ensurePolicy(ctx, projectID, owner.UserID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
ciphertext, err := s.codec.encrypt(normalized)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
_, err = s.store.CreateMember(ctx, MemberRecord{
|
||||
ID: uuid.NewString(),
|
||||
ProjectID: projectID,
|
||||
IdentifierType: identifierType,
|
||||
IdentifierHash: hashValue(identifierType + ":" + normalized),
|
||||
IdentifierCiphertext: ciphertext,
|
||||
Permission: permission,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateMember(ctx context.Context, projectID string, ownerID string, memberID string, permission Permission) (Settings, error) {
|
||||
policy, err := s.ownerPolicy(ctx, projectID, ownerID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if _, err := s.store.UpdateMember(ctx, projectID, strings.TrimSpace(memberID), permission, s.now().UTC()); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMember(ctx context.Context, projectID string, ownerID string, memberID string) (Settings, error) {
|
||||
policy, err := s.ownerPolicy(ctx, projectID, ownerID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if err := s.store.DeleteMember(ctx, projectID, strings.TrimSpace(memberID)); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.settingsFromPolicy(ctx, policy)
|
||||
}
|
||||
|
||||
func (s *Service) RevokeAll(ctx context.Context, projectID string, ownerID string) (Settings, error) {
|
||||
if policy, err := s.store.GetPolicyByProject(ctx, projectID); err == nil && policy.OwnerID != ownerID {
|
||||
return Settings{}, ErrForbidden
|
||||
} else if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
return Settings{}, err
|
||||
}
|
||||
if err := s.store.DeleteAll(ctx, projectID, ownerID); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveAccess(ctx context.Context, shareID string, actor Actor) (Access, error) {
|
||||
shareID = strings.TrimSpace(shareID)
|
||||
if !validShareID(shareID) {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
policy, err := s.store.GetPolicyByTokenHash(ctx, hashValue(shareID))
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
permission := policy.LinkPermission
|
||||
source := "link"
|
||||
isOwner := actor.Authenticated && actor.UserID != "" && actor.UserID == policy.OwnerID
|
||||
if isOwner {
|
||||
permission = PermissionOwner
|
||||
source = "owner"
|
||||
} else if actor.Authenticated {
|
||||
members, listErr := s.store.ListMembers(ctx, policy.ProjectID)
|
||||
if listErr != nil {
|
||||
return Access{}, listErr
|
||||
}
|
||||
if memberPermission, ok := permissionForActor(members, actor); ok {
|
||||
permission = memberPermission
|
||||
source = "member"
|
||||
}
|
||||
}
|
||||
if permission == PermissionPrivate {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
if permission == PermissionEditor && !actor.Authenticated {
|
||||
return Access{}, ErrLoginRequired
|
||||
}
|
||||
capabilities := permission.Capabilities()
|
||||
if !capabilities.CanViewCanvas {
|
||||
return Access{}, ErrForbidden
|
||||
}
|
||||
return Access{
|
||||
ShareID: shareID,
|
||||
ProjectID: policy.ProjectID,
|
||||
OwnerID: policy.OwnerID,
|
||||
Permission: permission,
|
||||
Source: source,
|
||||
Authenticated: actor.Authenticated,
|
||||
IsOwner: isOwner,
|
||||
Capabilities: capabilities,
|
||||
PolicyVersion: policy.Version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RecordVisit(ctx context.Context, access Access, actor Actor, visitorID string) error {
|
||||
if access.IsOwner {
|
||||
return nil
|
||||
}
|
||||
visitorKey := strings.TrimSpace(visitorID)
|
||||
userID := ""
|
||||
if actor.Authenticated && actor.UserID != "" {
|
||||
visitorKey = "user:" + actor.UserID
|
||||
userID = actor.UserID
|
||||
}
|
||||
if visitorKey == "" || len(visitorKey) > 128 {
|
||||
return nil
|
||||
}
|
||||
visitorHash := hashValue("visit:" + access.ProjectID + ":" + visitorKey)
|
||||
now := s.now().UTC()
|
||||
return s.store.RecordVisit(ctx, VisitRecord{
|
||||
ID: hashID(visitorHash),
|
||||
ProjectID: access.ProjectID,
|
||||
VisitorHash: visitorHash,
|
||||
UserID: userID,
|
||||
VisitCount: 1,
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Visitors(ctx context.Context, projectID string, ownerID string) ([]VisitRecord, error) {
|
||||
if _, err := s.ownerPolicy(ctx, projectID, ownerID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return []VisitRecord{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.store.ListVisitors(ctx, projectID, defaultVisitorLimit)
|
||||
}
|
||||
|
||||
func (s *Service) ensurePolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) {
|
||||
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
||||
if err == nil {
|
||||
if policy.OwnerID != ownerID {
|
||||
return PolicyRecord{}, ErrForbidden
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
shareID, err := s.codec.newShareID()
|
||||
if err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
ciphertext, err := s.codec.encrypt(shareID)
|
||||
if err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
return s.store.CreatePolicy(ctx, PolicyRecord{
|
||||
ProjectID: projectID,
|
||||
OwnerID: ownerID,
|
||||
TokenHash: hashValue(shareID),
|
||||
TokenCiphertext: ciphertext,
|
||||
LinkPermission: PermissionPrivate,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ownerPolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) {
|
||||
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
if policy.OwnerID != ownerID {
|
||||
return PolicyRecord{}, ErrForbidden
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Service) settingsFromPolicy(ctx context.Context, policy PolicyRecord) (Settings, error) {
|
||||
shareID, err := s.codec.decrypt(policy.TokenCiphertext)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
if !validShareID(shareID) || !hashesEqual(hashValue(shareID), policy.TokenHash) {
|
||||
return Settings{}, fmt.Errorf("%w: share token integrity check failed", ErrInvalidInput)
|
||||
}
|
||||
records, err := s.store.ListMembers(ctx, policy.ProjectID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
members := make([]Member, 0, len(records))
|
||||
for _, record := range records {
|
||||
identifier, decryptErr := s.codec.decrypt(record.IdentifierCiphertext)
|
||||
if decryptErr != nil {
|
||||
return Settings{}, decryptErr
|
||||
}
|
||||
members = append(members, Member{
|
||||
ID: record.ID,
|
||||
IdentifierType: record.IdentifierType,
|
||||
Identifier: identifier,
|
||||
Permission: record.Permission,
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
})
|
||||
}
|
||||
visitorCount, err := s.store.CountVisitors(ctx, policy.ProjectID)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return Settings{
|
||||
ProjectID: policy.ProjectID,
|
||||
OwnerID: policy.OwnerID,
|
||||
ShareID: shareID,
|
||||
LinkPermission: policy.LinkPermission,
|
||||
Members: members,
|
||||
VisitorCount: visitorCount,
|
||||
UpdatedAt: policy.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func permissionForActor(members []MemberRecord, actor Actor) (Permission, bool) {
|
||||
identifiers := actorIdentifiers(actor)
|
||||
for _, member := range members {
|
||||
for _, identifier := range identifiers {
|
||||
if hashesEqual(member.IdentifierHash, hashValue(identifier)) {
|
||||
return member.Permission, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
Reference in New Issue
Block a user