feat(server): add project sharing access control

This commit is contained in:
2026-07-10 14:46:18 +08:00
parent 60130b3d9b
commit 9d47cb9bbd
41 changed files with 3069 additions and 17 deletions
@@ -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
}
+163
View File
@@ -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}
}
+354
View File
@@ -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"]))
}
}
+165
View File
@@ -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
}