400 lines
11 KiB
Go
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[:])
|
|
}
|