feat(server): add project sharing access control
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package sharing
|
||||
|
||||
import "context"
|
||||
|
||||
type actorContextKey struct{}
|
||||
type accessContextKey struct{}
|
||||
|
||||
func ContextWithActor(ctx context.Context, actor Actor) context.Context {
|
||||
return context.WithValue(ctx, actorContextKey{}, actor)
|
||||
}
|
||||
|
||||
func ActorFromContext(ctx context.Context) Actor {
|
||||
if ctx == nil {
|
||||
return Actor{}
|
||||
}
|
||||
actor, _ := ctx.Value(actorContextKey{}).(Actor)
|
||||
return actor
|
||||
}
|
||||
|
||||
func ContextWithAccess(ctx context.Context, access Access) context.Context {
|
||||
return context.WithValue(ctx, accessContextKey{}, access)
|
||||
}
|
||||
|
||||
func AccessFromContext(ctx context.Context) (Access, bool) {
|
||||
if ctx == nil {
|
||||
return Access{}, false
|
||||
}
|
||||
access, ok := ctx.Value(accessContextKey{}).(Access)
|
||||
return access, ok
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const shareIDLength = 27
|
||||
|
||||
var (
|
||||
base62Alphabet = []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
shareIDPattern = regexp.MustCompile(`^[0-9A-Za-z]{27}$`)
|
||||
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
)
|
||||
|
||||
type secureCodec struct {
|
||||
aead cipher.AEAD
|
||||
rand io.Reader
|
||||
}
|
||||
|
||||
func newSecureCodec(secret string) (*secureCodec, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("%w: sharing encryption secret is required", ErrInvalidInput)
|
||||
}
|
||||
key := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &secureCodec{aead: aead, rand: rand.Reader}, nil
|
||||
}
|
||||
|
||||
func (c *secureCodec) newShareID() (string, error) {
|
||||
result := make([]byte, 0, shareIDLength)
|
||||
buffer := make([]byte, 64)
|
||||
for len(result) < shareIDLength {
|
||||
if _, err := io.ReadFull(c.rand, buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, value := range buffer {
|
||||
if value >= 248 {
|
||||
continue
|
||||
}
|
||||
result = append(result, base62Alphabet[int(value)%len(base62Alphabet)])
|
||||
if len(result) == shareIDLength {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func (c *secureCodec) encrypt(value string) ([]byte, error) {
|
||||
nonce := make([]byte, c.aead.NonceSize())
|
||||
if _, err := io.ReadFull(c.rand, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.aead.Seal(nonce, nonce, []byte(value), nil), nil
|
||||
}
|
||||
|
||||
func (c *secureCodec) decrypt(value []byte) (string, error) {
|
||||
if len(value) < c.aead.NonceSize() {
|
||||
return "", fmt.Errorf("%w: encrypted share value is invalid", ErrInvalidInput)
|
||||
}
|
||||
nonce := value[:c.aead.NonceSize()]
|
||||
plaintext, err := c.aead.Open(nil, nonce, value[c.aead.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func hashValue(value string) []byte {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return digest[:]
|
||||
}
|
||||
|
||||
func hashID(value []byte) string {
|
||||
if len(value) > 16 {
|
||||
value = value[:16]
|
||||
}
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func hashesEqual(left []byte, right []byte) bool {
|
||||
return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1
|
||||
}
|
||||
|
||||
func validShareID(value string) bool {
|
||||
return shareIDPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func normalizeIdentifier(value string) (string, string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", fmt.Errorf("%w: email or phone is required", ErrInvalidInput)
|
||||
}
|
||||
if strings.Contains(value, "@") {
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || !strings.EqualFold(strings.TrimSpace(address.Address), value) {
|
||||
return "", "", fmt.Errorf("%w: email is invalid", ErrInvalidInput)
|
||||
}
|
||||
return "email", strings.ToLower(address.Address), nil
|
||||
}
|
||||
phone := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(value)
|
||||
if !phonePattern.MatchString(phone) {
|
||||
return "", "", fmt.Errorf("%w: phone is invalid", ErrInvalidInput)
|
||||
}
|
||||
return "phone", phone, nil
|
||||
}
|
||||
|
||||
func actorIdentifiers(actor Actor) []string {
|
||||
seen := make(map[string]struct{})
|
||||
appendIdentifier := func(values *[]string, value string) {
|
||||
kind, normalized, err := normalizeIdentifier(value)
|
||||
if err != nil || kind == "" {
|
||||
return
|
||||
}
|
||||
key := kind + ":" + normalized
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
*values = append(*values, key)
|
||||
}
|
||||
values := make([]string, 0, 3)
|
||||
appendIdentifier(&values, actor.Email)
|
||||
appendIdentifier(&values, actor.Phone)
|
||||
if strings.TrimSpace(actor.CountryCode) != "" && strings.TrimSpace(actor.Phone) != "" {
|
||||
countryCode := strings.TrimPrefix(strings.TrimSpace(actor.CountryCode), "+")
|
||||
phone := strings.TrimPrefix(strings.TrimSpace(actor.Phone), "+")
|
||||
appendIdentifier(&values, "+"+countryCode+phone)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func ActorOwnsIdentifier(actor Actor, identifier string) bool {
|
||||
identifierType, normalized, err := normalizeIdentifier(identifier)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
candidateHash := hashValue(identifierType + ":" + normalized)
|
||||
for _, actorIdentifier := range actorIdentifiers(actor) {
|
||||
if hashesEqual(candidateHash, hashValue(actorIdentifier)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
policies map[string]PolicyRecord
|
||||
tokens map[string]string
|
||||
members map[string]map[string]MemberRecord
|
||||
visits map[string]map[string]VisitRecord
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
policies: make(map[string]PolicyRecord),
|
||||
tokens: make(map[string]string),
|
||||
members: make(map[string]map[string]MemberRecord),
|
||||
visits: make(map[string]map[string]VisitRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
policy, ok := s.policies[projectID]
|
||||
if !ok {
|
||||
return PolicyRecord{}, ErrNotFound
|
||||
}
|
||||
return clonePolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
projectID, ok := s.tokens[string(tokenHash)]
|
||||
if !ok {
|
||||
return PolicyRecord{}, ErrNotFound
|
||||
}
|
||||
policy, ok := s.policies[projectID]
|
||||
if !ok {
|
||||
return PolicyRecord{}, ErrNotFound
|
||||
}
|
||||
return clonePolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if existing, ok := s.policies[policy.ProjectID]; ok {
|
||||
return clonePolicy(existing), nil
|
||||
}
|
||||
policy = clonePolicy(policy)
|
||||
s.policies[policy.ProjectID] = policy
|
||||
s.tokens[string(policy.TokenHash)] = policy.ProjectID
|
||||
return clonePolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
policy, ok := s.policies[projectID]
|
||||
if !ok {
|
||||
return PolicyRecord{}, ErrNotFound
|
||||
}
|
||||
if policy.OwnerID != ownerID {
|
||||
return PolicyRecord{}, ErrForbidden
|
||||
}
|
||||
policy.LinkPermission = permission
|
||||
policy.Version++
|
||||
policy.UpdatedAt = updatedAt
|
||||
s.policies[projectID] = policy
|
||||
return clonePolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
policy, ok := s.policies[projectID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if policy.OwnerID != ownerID {
|
||||
return ErrForbidden
|
||||
}
|
||||
delete(s.tokens, string(policy.TokenHash))
|
||||
delete(s.policies, projectID)
|
||||
delete(s.members, projectID)
|
||||
delete(s.visits, projectID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]MemberRecord, 0, len(s.members[projectID]))
|
||||
for _, member := range s.members[projectID] {
|
||||
items = append(items, cloneMember(member))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return MemberRecord{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.policies[member.ProjectID]; !ok {
|
||||
return MemberRecord{}, ErrNotFound
|
||||
}
|
||||
if s.members[member.ProjectID] == nil {
|
||||
s.members[member.ProjectID] = make(map[string]MemberRecord)
|
||||
}
|
||||
for _, existing := range s.members[member.ProjectID] {
|
||||
if existing.IdentifierType == member.IdentifierType && hashesEqual(existing.IdentifierHash, member.IdentifierHash) {
|
||||
return MemberRecord{}, ErrMemberExists
|
||||
}
|
||||
}
|
||||
member = cloneMember(member)
|
||||
s.members[member.ProjectID][member.ID] = member
|
||||
return cloneMember(member), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return MemberRecord{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
member, ok := s.members[projectID][memberID]
|
||||
if !ok {
|
||||
return MemberRecord{}, ErrNotFound
|
||||
}
|
||||
member.Permission = permission
|
||||
member.UpdatedAt = updatedAt
|
||||
s.members[projectID][memberID] = member
|
||||
return cloneMember(member), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) DeleteMember(ctx context.Context, projectID string, memberID string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.members[projectID][memberID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.members[projectID], memberID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) CountVisitors(ctx context.Context, projectID string) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return int64(len(s.visits[projectID])), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) RecordVisit(ctx context.Context, visit VisitRecord) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.policies[visit.ProjectID]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if s.visits[visit.ProjectID] == nil {
|
||||
s.visits[visit.ProjectID] = make(map[string]VisitRecord)
|
||||
}
|
||||
key := string(visit.VisitorHash)
|
||||
if current, ok := s.visits[visit.ProjectID][key]; ok {
|
||||
current.VisitCount++
|
||||
current.LastSeenAt = visit.LastSeenAt
|
||||
if visit.UserID != "" {
|
||||
current.UserID = visit.UserID
|
||||
}
|
||||
s.visits[visit.ProjectID][key] = current
|
||||
return nil
|
||||
}
|
||||
s.visits[visit.ProjectID][key] = cloneVisit(visit)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]VisitRecord, 0, len(s.visits[projectID]))
|
||||
for _, visit := range s.visits[projectID] {
|
||||
items = append(items, cloneVisit(visit))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].LastSeenAt.After(items[j].LastSeenAt) })
|
||||
if limit > 0 && int64(len(items)) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Close() error { return nil }
|
||||
|
||||
func clonePolicy(policy PolicyRecord) PolicyRecord {
|
||||
policy.TokenHash = append([]byte(nil), policy.TokenHash...)
|
||||
policy.TokenCiphertext = append([]byte(nil), policy.TokenCiphertext...)
|
||||
return policy
|
||||
}
|
||||
|
||||
func cloneMember(member MemberRecord) MemberRecord {
|
||||
member.IdentifierHash = append([]byte(nil), member.IdentifierHash...)
|
||||
member.IdentifierCiphertext = append([]byte(nil), member.IdentifierCiphertext...)
|
||||
return member
|
||||
}
|
||||
|
||||
func cloneVisit(visit VisitRecord) VisitRecord {
|
||||
visit.VisitorHash = append([]byte(nil), visit.VisitorHash...)
|
||||
return visit
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PostgresStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresStore(ctx context.Context, dataSource string) (*PostgresStore, error) {
|
||||
pool, err := pgxpool.New(ctx, dataSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &PostgresStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) {
|
||||
return scanPolicy(s.pool.QueryRow(ctx, `
|
||||
SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
|
||||
FROM project_share_policies
|
||||
WHERE project_id = $1
|
||||
`, projectID))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) {
|
||||
return scanPolicy(s.pool.QueryRow(ctx, `
|
||||
SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
|
||||
FROM project_share_policies
|
||||
WHERE token_hash = $1
|
||||
`, tokenHash))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) {
|
||||
created, err := scanPolicy(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO project_share_policies (
|
||||
project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (project_id) DO NOTHING
|
||||
RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
|
||||
`, policy.ProjectID, toPGUUID(policy.OwnerID), policy.TokenHash, policy.TokenCiphertext, string(policy.LinkPermission), policy.Version, policy.CreatedAt, policy.UpdatedAt))
|
||||
if err == nil {
|
||||
return created, nil
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return s.GetPolicyByProject(ctx, policy.ProjectID)
|
||||
}
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) {
|
||||
return scanPolicy(s.pool.QueryRow(ctx, `
|
||||
UPDATE project_share_policies
|
||||
SET link_permission = $3, policy_version = policy_version + 1, updated_at = $4
|
||||
WHERE project_id = $1 AND owner_id = $2
|
||||
RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at
|
||||
`, projectID, toPGUUID(ownerID), string(permission), updatedAt))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM project_share_policies WHERE project_id = $1 AND owner_id = $2`, projectID, toPGUUID(ownerID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
if _, getErr := s.GetPolicyByProject(ctx, projectID); getErr == nil {
|
||||
return ErrForbidden
|
||||
} else if !errors.Is(getErr, ErrNotFound) {
|
||||
return getErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
|
||||
FROM project_share_members
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]MemberRecord, 0)
|
||||
for rows.Next() {
|
||||
member, scanErr := scanMember(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, member)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) {
|
||||
created, err := scanMember(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO project_share_members (
|
||||
id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
|
||||
`, toPGUUID(member.ID), member.ProjectID, member.IdentifierType, member.IdentifierHash, member.IdentifierCiphertext, string(member.Permission), member.CreatedAt, member.UpdatedAt))
|
||||
if err != nil {
|
||||
var postgresErr *pgconn.PgError
|
||||
if errors.As(err, &postgresErr) && postgresErr.Code == "23505" {
|
||||
return MemberRecord{}, ErrMemberExists
|
||||
}
|
||||
return MemberRecord{}, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) {
|
||||
return scanMember(s.pool.QueryRow(ctx, `
|
||||
UPDATE project_share_members
|
||||
SET permission = $3, updated_at = $4
|
||||
WHERE project_id = $1 AND id = $2
|
||||
RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at
|
||||
`, projectID, toPGUUID(memberID), string(permission), updatedAt))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) DeleteMember(ctx context.Context, projectID string, memberID string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM project_share_members WHERE project_id = $1 AND id = $2`, projectID, toPGUUID(memberID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CountVisitors(ctx context.Context, projectID string) (int64, error) {
|
||||
var count int64
|
||||
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_share_visits WHERE project_id = $1`, projectID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RecordVisit(ctx context.Context, visit VisitRecord) error {
|
||||
var userID any
|
||||
if visit.UserID != "" {
|
||||
userID = toPGUUID(visit.UserID)
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO project_share_visits (
|
||||
id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 1, $5, $6)
|
||||
ON CONFLICT (project_id, visitor_hash) DO UPDATE SET
|
||||
user_id = COALESCE(EXCLUDED.user_id, project_share_visits.user_id),
|
||||
visit_count = project_share_visits.visit_count + 1,
|
||||
last_seen_at = EXCLUDED.last_seen_at
|
||||
`, visit.ID, visit.ProjectID, visit.VisitorHash, userID, visit.FirstSeenAt, visit.LastSeenAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at
|
||||
FROM project_share_visits
|
||||
WHERE project_id = $1
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT $2
|
||||
`, projectID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]VisitRecord, 0)
|
||||
for rows.Next() {
|
||||
var visit VisitRecord
|
||||
var userID pgtype.UUID
|
||||
if err := rows.Scan(&visit.ID, &visit.ProjectID, &visit.VisitorHash, &userID, &visit.VisitCount, &visit.FirstSeenAt, &visit.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
visit.UserID = uuid.UUID(userID.Bytes).String()
|
||||
}
|
||||
items = append(items, visit)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PostgresStore) Close() error {
|
||||
if s != nil && s.pool != nil {
|
||||
s.pool.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPolicy(row rowScanner) (PolicyRecord, error) {
|
||||
var policy PolicyRecord
|
||||
var ownerID pgtype.UUID
|
||||
if err := row.Scan(&policy.ProjectID, &ownerID, &policy.TokenHash, &policy.TokenCiphertext, &policy.LinkPermission, &policy.Version, &policy.CreatedAt, &policy.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PolicyRecord{}, ErrNotFound
|
||||
}
|
||||
return PolicyRecord{}, err
|
||||
}
|
||||
if ownerID.Valid {
|
||||
policy.OwnerID = uuid.UUID(ownerID.Bytes).String()
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func scanMember(row rowScanner) (MemberRecord, error) {
|
||||
var member MemberRecord
|
||||
var id pgtype.UUID
|
||||
if err := row.Scan(&id, &member.ProjectID, &member.IdentifierType, &member.IdentifierHash, &member.IdentifierCiphertext, &member.Permission, &member.CreatedAt, &member.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return MemberRecord{}, ErrNotFound
|
||||
}
|
||||
return MemberRecord{}, err
|
||||
}
|
||||
if id.Valid {
|
||||
member.ID = uuid.UUID(id.Bytes).String()
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func toPGUUID(value string) pgtype.UUID {
|
||||
parsed, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
return pgtype.UUID{}
|
||||
}
|
||||
return pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testOwnerID = "91cd197b-8255-4172-9981-77391fd38f33"
|
||||
testMemberID = "ea4dc900-39a1-45e7-a652-2430499f1a5f"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *MemoryStore) {
|
||||
t.Helper()
|
||||
store := NewMemoryStore()
|
||||
service, err := NewService(store, "test-sharing-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, store
|
||||
}
|
||||
|
||||
func testOwnerActor() Actor {
|
||||
return Actor{Authenticated: true, UserID: testOwnerID}
|
||||
}
|
||||
|
||||
func TestResolveAccessUsesMemberBeforeLink(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
ctx := context.Background()
|
||||
settings, err := service.UpdateLink(ctx, "project-1", testOwnerID, PermissionCanvas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(settings.ShareID) != shareIDLength || !shareIDPattern.MatchString(settings.ShareID) {
|
||||
t.Fatalf("expected a %d-character case-sensitive base62 id, got %q", shareIDLength, settings.ShareID)
|
||||
}
|
||||
if _, err := service.Invite(ctx, "project-1", testOwnerActor(), "Editor@Example.com", PermissionEditor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
linkAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if linkAccess.Permission != PermissionCanvas || linkAccess.Capabilities.CanViewChat || linkAccess.Capabilities.CanEdit {
|
||||
t.Fatalf("unexpected anonymous canvas access: %+v", linkAccess)
|
||||
}
|
||||
|
||||
memberAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{
|
||||
Authenticated: true,
|
||||
UserID: testMemberID,
|
||||
Email: "editor@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memberAccess.Source != "member" || memberAccess.Permission != PermissionEditor || !memberAccess.Capabilities.CanEdit {
|
||||
t.Fatalf("expected explicit editor access, got %+v", memberAccess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorLinkRequiresAuthentication(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionEditor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrLoginRequired) {
|
||||
t.Fatalf("expected login required, got %v", err)
|
||||
}
|
||||
access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{Authenticated: true, UserID: testMemberID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !access.Capabilities.CanEdit || access.Source != "link" {
|
||||
t.Fatalf("expected authenticated link editor, got %+v", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllInvalidatesTokenAndMembers(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "+8613800138000", PermissionEditor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.RevokeAll(context.Background(), "project-1", testOwnerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected revoked link to be missing, got %v", err)
|
||||
}
|
||||
privateSettings, err := service.Settings(context.Background(), "project-1", testOwnerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if privateSettings.ShareID != "" || privateSettings.LinkPermission != PermissionPrivate || len(privateSettings.Members) != 0 {
|
||||
t.Fatalf("expected private empty settings, got %+v", privateSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareIDIsCaseSensitive(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutated := []byte(settings.ShareID)
|
||||
for index, value := range mutated {
|
||||
if value >= 'a' && value <= 'z' {
|
||||
mutated[index] = value - ('a' - 'A')
|
||||
break
|
||||
}
|
||||
if value >= 'A' && value <= 'Z' {
|
||||
mutated[index] = value + ('a' - 'A')
|
||||
break
|
||||
}
|
||||
}
|
||||
if string(mutated) == settings.ShareID {
|
||||
t.Skip("random token contained no letters")
|
||||
}
|
||||
if _, err := service.ResolveAccess(context.Background(), string(mutated), Actor{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected case-mutated token to fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveValuesAreEncryptedAtRest(t *testing.T) {
|
||||
service, store := newTestService(t)
|
||||
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionCanvas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "private@example.com", PermissionViewer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := store.policies["project-1"]
|
||||
if strings.Contains(string(policy.TokenCiphertext), settings.ShareID) {
|
||||
t.Fatal("share id must not be stored in plaintext")
|
||||
}
|
||||
for _, member := range store.members["project-1"] {
|
||||
if strings.Contains(string(member.IdentifierCiphertext), "private@example.com") {
|
||||
t.Fatal("member identifier must not be stored in plaintext")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordVisitDeduplicatesVisitor(t *testing.T) {
|
||||
service, _ := newTestService(t)
|
||||
settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visitors, err := service.Visitors(context.Background(), "project-1", testOwnerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(visitors) != 1 || visitors[0].VisitCount != 2 {
|
||||
t.Fatalf("expected one repeat visitor, got %+v", visitors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteRejectsOwnerIdentifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identifier string
|
||||
}{
|
||||
{name: "email is case insensitive", identifier: " OWNER@EXAMPLE.COM "},
|
||||
{name: "local phone is normalized", identifier: "138-0013-8000"},
|
||||
{name: "international phone is normalized", identifier: "+86 138 0013 8000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service, store := newTestService(t)
|
||||
owner := Actor{
|
||||
Authenticated: true,
|
||||
UserID: testOwnerID,
|
||||
Email: "owner@example.com",
|
||||
CountryCode: "+86",
|
||||
Phone: "13800138000",
|
||||
}
|
||||
|
||||
if _, err := service.Invite(context.Background(), "project-1", owner, tt.identifier, PermissionViewer); !errors.Is(err, ErrSelfInvite) {
|
||||
t.Fatalf("expected self-invite rejection, got %v", err)
|
||||
}
|
||||
if len(store.members["project-1"]) != 0 {
|
||||
t.Fatal("self-invite must not create a member")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteRejectsNormalizedDuplicateWithoutChangingPermission(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
first string
|
||||
duplicate string
|
||||
}{
|
||||
{name: "email", first: "Member@Example.com", duplicate: " member@example.com "},
|
||||
{name: "phone", first: "+86 139-0013-9000", duplicate: "+8613900139000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service, store := newTestService(t)
|
||||
ctx := context.Background()
|
||||
owner := testOwnerActor()
|
||||
|
||||
if _, err := service.Invite(ctx, "project-1", owner, tt.first, PermissionViewer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Invite(ctx, "project-1", owner, tt.duplicate, PermissionEditor); !errors.Is(err, ErrMemberExists) {
|
||||
t.Fatalf("expected duplicate member rejection, got %v", err)
|
||||
}
|
||||
|
||||
members := store.members["project-1"]
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("expected one member, got %d", len(members))
|
||||
}
|
||||
for _, member := range members {
|
||||
if member.Permission != PermissionViewer {
|
||||
t.Fatalf("duplicate invite changed permission to %q", member.Permission)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteCreatesOneMemberUnderConcurrency(t *testing.T) {
|
||||
service, store := newTestService(t)
|
||||
owner := testOwnerActor()
|
||||
const attempts = 12
|
||||
|
||||
start := make(chan struct{})
|
||||
errorsByAttempt := make(chan error, attempts)
|
||||
var workers sync.WaitGroup
|
||||
for index := 0; index < attempts; index++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
_, err := service.Invite(context.Background(), "project-1", owner, "concurrent@example.com", PermissionViewer)
|
||||
errorsByAttempt <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
close(errorsByAttempt)
|
||||
|
||||
successes := 0
|
||||
duplicates := 0
|
||||
for err := range errorsByAttempt {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrMemberExists):
|
||||
duplicates++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent invite error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || duplicates != attempts-1 {
|
||||
t.Fatalf("expected one success and %d duplicates, got %d and %d", attempts-1, successes, duplicates)
|
||||
}
|
||||
if len(store.members["project-1"]) != 1 {
|
||||
t.Fatalf("expected exactly one stored member, got %d", len(store.members["project-1"]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("share not found")
|
||||
ErrForbidden = errors.New("share access forbidden")
|
||||
ErrLoginRequired = errors.New("login required for edit access")
|
||||
ErrInvalidInput = errors.New("invalid share input")
|
||||
ErrSelfInvite = errors.New("project owner cannot be invited")
|
||||
ErrMemberExists = errors.New("share member already exists")
|
||||
)
|
||||
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermissionPrivate Permission = "private"
|
||||
PermissionCanvas Permission = "canvas"
|
||||
PermissionViewer Permission = "viewer"
|
||||
PermissionEditor Permission = "editor"
|
||||
PermissionOwner Permission = "owner"
|
||||
)
|
||||
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityViewCanvas Capability = "view_canvas"
|
||||
CapabilityViewChat Capability = "view_chat"
|
||||
CapabilityEdit Capability = "edit"
|
||||
CapabilityManage Capability = "manage"
|
||||
)
|
||||
|
||||
type Capabilities struct {
|
||||
CanViewCanvas bool
|
||||
CanViewChat bool
|
||||
CanEdit bool
|
||||
CanManage bool
|
||||
}
|
||||
|
||||
func (p Permission) Capabilities() Capabilities {
|
||||
switch p {
|
||||
case PermissionOwner:
|
||||
return Capabilities{CanViewCanvas: true, CanViewChat: true, CanEdit: true, CanManage: true}
|
||||
case PermissionEditor:
|
||||
return Capabilities{CanViewCanvas: true, CanViewChat: true, CanEdit: true}
|
||||
case PermissionViewer:
|
||||
return Capabilities{CanViewCanvas: true, CanViewChat: true}
|
||||
case PermissionCanvas:
|
||||
return Capabilities{CanViewCanvas: true}
|
||||
default:
|
||||
return Capabilities{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c Capabilities) Allows(capability Capability) bool {
|
||||
switch capability {
|
||||
case CapabilityManage:
|
||||
return c.CanManage
|
||||
case CapabilityEdit:
|
||||
return c.CanEdit
|
||||
case CapabilityViewChat:
|
||||
return c.CanViewChat
|
||||
case CapabilityViewCanvas:
|
||||
return c.CanViewCanvas
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type Actor struct {
|
||||
Authenticated bool
|
||||
UserID string
|
||||
Email string
|
||||
CountryCode string
|
||||
Phone string
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
type Access struct {
|
||||
ShareID string
|
||||
ProjectID string
|
||||
OwnerID string
|
||||
Permission Permission
|
||||
Source string
|
||||
Authenticated bool
|
||||
IsOwner bool
|
||||
Capabilities Capabilities
|
||||
PolicyVersion int64
|
||||
}
|
||||
|
||||
func (a Access) Allows(capability Capability) bool {
|
||||
return a.Capabilities.Allows(capability)
|
||||
}
|
||||
|
||||
type PolicyRecord struct {
|
||||
ProjectID string
|
||||
OwnerID string
|
||||
TokenHash []byte
|
||||
TokenCiphertext []byte
|
||||
LinkPermission Permission
|
||||
Version int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type MemberRecord struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
IdentifierType string
|
||||
IdentifierHash []byte
|
||||
IdentifierCiphertext []byte
|
||||
Permission Permission
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID string
|
||||
IdentifierType string
|
||||
Identifier string
|
||||
Permission Permission
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type VisitRecord struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
VisitorHash []byte
|
||||
UserID string
|
||||
VisitCount int64
|
||||
FirstSeenAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ProjectID string
|
||||
OwnerID string
|
||||
ShareID string
|
||||
LinkPermission Permission
|
||||
Members []Member
|
||||
VisitorCount int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error)
|
||||
GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error)
|
||||
CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error)
|
||||
UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error)
|
||||
DeleteAll(ctx context.Context, projectID string, ownerID string) error
|
||||
ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error)
|
||||
CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error)
|
||||
UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error)
|
||||
DeleteMember(ctx context.Context, projectID string, memberID string) error
|
||||
CountVisitors(ctx context.Context, projectID string) (int64, error)
|
||||
RecordVisit(ctx context.Context, visit VisitRecord) error
|
||||
ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error)
|
||||
Close() error
|
||||
}
|
||||
Reference in New Issue
Block a user