Scout/internal/access/store.go
2026-08-31 21:19:15 +08:00

400 lines
11 KiB
Go

package access
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const (
RoleViewer = "project_viewer"
RoleDeveloper = "project_developer"
RoleAdmin = "project_admin"
)
type ProjectMember struct {
ProjectID string `json:"project_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Role string `json:"role"`
SecretHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
type persistedProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Role string `json:"role"`
SecretHash string `json:"secret_hash"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
type database struct {
Members []ProjectMember `json:"members"`
Tokens []persistedProjectToken `json:"tokens"`
}
type Store struct {
path string
mu sync.RWMutex
members map[string]map[string]ProjectMember
tokens map[string]ProjectToken
}
func New(path string) (*Store, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("项目权限数据文件不能为空")
}
store := &Store{
path: path,
members: make(map[string]map[string]ProjectMember),
tokens: make(map[string]ProjectToken),
}
if err := store.load(); err != nil {
return nil, err
}
return store, nil
}
func ValidateRole(role string) error {
switch strings.TrimSpace(role) {
case RoleViewer, RoleDeveloper, RoleAdmin:
return nil
default:
return errors.New("项目角色无效")
}
}
func Allows(role, required string) bool {
levels := map[string]int{RoleViewer: 1, RoleDeveloper: 2, RoleAdmin: 3}
return levels[strings.TrimSpace(role)] >= levels[strings.TrimSpace(required)] && levels[strings.TrimSpace(required)] > 0
}
func (s *Store) HasAccessControl(projectID string) bool {
projectID = strings.TrimSpace(projectID)
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.members[projectID]) > 0 {
return true
}
for _, token := range s.tokens {
if token.ProjectID == projectID {
return true
}
}
return false
}
func (s *Store) ListMembers(projectID string) []ProjectMember {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]ProjectMember, 0, len(s.members[projectID]))
for _, member := range s.members[projectID] {
result = append(result, member)
}
sort.Slice(result, func(i, j int) bool { return result[i].UserID < result[j].UserID })
return result
}
func (s *Store) GetMember(projectID, userID string) (ProjectMember, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
member, ok := s.members[strings.TrimSpace(projectID)][strings.TrimSpace(userID)]
return member, ok
}
func (s *Store) AddMember(projectID, userID, role string) (ProjectMember, error) {
projectID = strings.TrimSpace(projectID)
userID = strings.TrimSpace(userID)
role = strings.TrimSpace(role)
if projectID == "" || userID == "" {
return ProjectMember{}, errors.New("项目标识和用户标识不能为空")
}
if err := ValidateRole(role); err != nil {
return ProjectMember{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.members[projectID] == nil {
s.members[projectID] = make(map[string]ProjectMember)
}
if _, exists := s.members[projectID][userID]; exists {
return ProjectMember{}, errors.New("项目成员已经存在")
}
now := time.Now().UTC()
member := ProjectMember{ProjectID: projectID, UserID: userID, Role: role, CreatedAt: now, UpdatedAt: now}
s.members[projectID][userID] = member
if err := s.saveLocked(); err != nil {
delete(s.members[projectID], userID)
return ProjectMember{}, err
}
return member, nil
}
func (s *Store) UpdateMember(projectID, userID, role string) (ProjectMember, error) {
projectID = strings.TrimSpace(projectID)
userID = strings.TrimSpace(userID)
role = strings.TrimSpace(role)
if err := ValidateRole(role); err != nil {
return ProjectMember{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
member, exists := s.members[projectID][userID]
if !exists {
return ProjectMember{}, errors.New("项目成员不存在")
}
if member.Role == RoleAdmin && role != RoleAdmin && s.adminCountLocked(projectID) <= 1 {
return ProjectMember{}, errors.New("项目至少需要保留一名管理员")
}
member.Role = role
member.UpdatedAt = time.Now().UTC()
s.members[projectID][userID] = member
if err := s.saveLocked(); err != nil {
return ProjectMember{}, err
}
return member, nil
}
func (s *Store) DeleteMember(projectID, userID string) error {
projectID = strings.TrimSpace(projectID)
userID = strings.TrimSpace(userID)
s.mu.Lock()
defer s.mu.Unlock()
member, exists := s.members[projectID][userID]
if !exists {
return errors.New("项目成员不存在")
}
if member.Role == RoleAdmin && s.adminCountLocked(projectID) <= 1 {
return errors.New("项目至少需要保留一名管理员")
}
delete(s.members[projectID], userID)
return s.saveLocked()
}
func (s *Store) adminCountLocked(projectID string) int {
count := 0
for _, member := range s.members[strings.TrimSpace(projectID)] {
if member.Role == RoleAdmin {
count++
}
}
return count
}
func (s *Store) CreateToken(projectID, name, role string, expiresAt *time.Time) (ProjectToken, string, error) {
projectID = strings.TrimSpace(projectID)
name = strings.TrimSpace(name)
role = strings.TrimSpace(role)
if projectID == "" || name == "" {
return ProjectToken{}, "", errors.New("项目标识和令牌名称不能为空")
}
if len(name) > 80 {
return ProjectToken{}, "", errors.New("令牌名称长度不能超过 80 个字符")
}
if err := ValidateRole(role); err != nil {
return ProjectToken{}, "", err
}
if role == RoleAdmin {
return ProjectToken{}, "", errors.New("项目令牌不能授予项目管理员权限")
}
secretBytes, err := randomBytes(32)
if err != nil {
return ProjectToken{}, "", fmt.Errorf("生成项目令牌失败: %w", err)
}
secret := "sct_" + base64.RawURLEncoding.EncodeToString(secretBytes)
idBytes, err := randomBytes(16)
if err != nil {
return ProjectToken{}, "", fmt.Errorf("生成令牌标识失败: %w", err)
}
id := "tok_" + base64.RawURLEncoding.EncodeToString(idBytes)
now := time.Now().UTC()
token := ProjectToken{
ID: id,
ProjectID: projectID,
Name: name,
Prefix: secret[:12],
Role: role,
SecretHash: hashSecret(secret),
CreatedAt: now,
ExpiresAt: expiresAt,
}
s.mu.Lock()
s.tokens[id] = token
if err := s.saveLocked(); err != nil {
delete(s.tokens, id)
s.mu.Unlock()
return ProjectToken{}, "", err
}
safe := token
safe.SecretHash = ""
s.mu.Unlock()
return safe, secret, nil
}
func (s *Store) ListTokens(projectID string) []ProjectToken {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]ProjectToken, 0)
for _, token := range s.tokens {
if token.ProjectID != strings.TrimSpace(projectID) {
continue
}
token.SecretHash = ""
result = append(result, token)
}
sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) })
return result
}
func (s *Store) RevokeToken(projectID, tokenID string) (ProjectToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
token, exists := s.tokens[strings.TrimSpace(tokenID)]
if !exists || token.ProjectID != strings.TrimSpace(projectID) {
return ProjectToken{}, errors.New("项目令牌不存在")
}
if token.RevokedAt == nil {
now := time.Now().UTC()
token.RevokedAt = &now
s.tokens[token.ID] = token
if err := s.saveLocked(); err != nil {
return ProjectToken{}, err
}
}
token.SecretHash = ""
return token, nil
}
func (s *Store) AuthenticateToken(projectID, secret string) (ProjectToken, bool) {
projectID = strings.TrimSpace(projectID)
secret = strings.TrimSpace(secret)
if projectID == "" || secret == "" {
return ProjectToken{}, false
}
hash := hashSecret(secret)
now := time.Now().UTC()
s.mu.RLock()
defer s.mu.RUnlock()
for _, token := range s.tokens {
if token.ProjectID != projectID || token.RevokedAt != nil || (token.ExpiresAt != nil && now.After(*token.ExpiresAt)) {
continue
}
if subtle.ConstantTimeCompare([]byte(token.SecretHash), []byte(hash)) == 1 {
token.SecretHash = ""
return token, true
}
}
return ProjectToken{}, false
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("读取项目权限数据失败: %w", err)
}
var stored database
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("解析项目权限数据失败: %w", err)
}
for _, member := range stored.Members {
if member.ProjectID == "" || member.UserID == "" || ValidateRole(member.Role) != nil {
continue
}
if s.members[member.ProjectID] == nil {
s.members[member.ProjectID] = make(map[string]ProjectMember)
}
s.members[member.ProjectID][member.UserID] = member
}
for _, token := range stored.Tokens {
if token.ID != "" && token.ProjectID != "" && token.SecretHash != "" && ValidateRole(token.Role) == nil && token.Role != RoleAdmin {
s.tokens[token.ID] = ProjectToken{
ID: token.ID, ProjectID: token.ProjectID, Name: token.Name, Prefix: token.Prefix,
Role: token.Role, SecretHash: token.SecretHash, CreatedAt: token.CreatedAt,
ExpiresAt: token.ExpiresAt, RevokedAt: token.RevokedAt,
}
}
}
return nil
}
func (s *Store) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("创建项目权限数据目录失败: %w", err)
}
stored := database{Members: make([]ProjectMember, 0), Tokens: make([]persistedProjectToken, 0, len(s.tokens))}
for _, members := range s.members {
for _, member := range members {
stored.Members = append(stored.Members, member)
}
}
for _, token := range s.tokens {
stored.Tokens = append(stored.Tokens, persistedProjectToken{
ID: token.ID, ProjectID: token.ProjectID, Name: token.Name, Prefix: token.Prefix,
Role: token.Role, SecretHash: token.SecretHash, CreatedAt: token.CreatedAt,
ExpiresAt: token.ExpiresAt, RevokedAt: token.RevokedAt,
})
}
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("编码项目权限数据失败: %w", err)
}
temp, err := os.CreateTemp(filepath.Dir(s.path), ".project-access-*")
if err != nil {
return fmt.Errorf("创建项目权限临时文件失败: %w", err)
}
tempName := temp.Name()
defer os.Remove(tempName)
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("写入项目权限数据失败: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("关闭项目权限临时文件失败: %w", err)
}
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("保存项目权限数据失败: %w", err)
}
return nil
}
func randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
_, err := rand.Read(value)
return value, err
}
func hashSecret(secret string) string {
hash := sha256.Sum256([]byte(secret))
return hex.EncodeToString(hash[:])
}