feat(server): add project sharing access control
This commit is contained in:
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user