diff --git a/cmd/scout-server/main.go b/cmd/scout-server/main.go index 1030a02..ee2575f 100644 --- a/cmd/scout-server/main.go +++ b/cmd/scout-server/main.go @@ -10,6 +10,7 @@ import ( "path/filepath" "syscall" + "scout/internal/access" "scout/internal/config" "scout/internal/httpapi" "scout/internal/notify" @@ -32,6 +33,10 @@ func main() { if err != nil { logger.Fatal(err) } + accessStore, err := access.New(filepath.Join(cfg.DataDir, "project-access.json")) + if err != nil { + logger.Fatal(err) + } userStore, err := users.New(filepath.Join(cfg.DataDir, "users.json")) if err != nil { logger.Fatal(err) @@ -45,6 +50,7 @@ func main() { api := &httpapi.Server{ Registry: store, Projects: projectStore, + Access: accessStore, DeveloperToken: cfg.DeveloperToken, AllowLegacyToken: cfg.AllowLegacyToken, MaxUploadBytes: cfg.MaxUploadBytes, diff --git a/internal/access/store.go b/internal/access/store.go new file mode 100644 index 0000000..685f63a --- /dev/null +++ b/internal/access/store.go @@ -0,0 +1,399 @@ +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[:]) +} diff --git a/internal/access/store_test.go b/internal/access/store_test.go new file mode 100644 index 0000000..555e419 --- /dev/null +++ b/internal/access/store_test.go @@ -0,0 +1,104 @@ +package access + +import ( + "path/filepath" + "testing" + "time" +) + +func TestStorePersistsMembersAndTokens(t *testing.T) { + path := filepath.Join(t.TempDir(), "project-access.json") + store, err := New(path) + if err != nil { + t.Fatal(err) + } + member, err := store.AddMember("ssl", "user-1", RoleDeveloper) + if err != nil { + t.Fatal(err) + } + if member.Role != RoleDeveloper || !store.HasAccessControl("ssl") { + t.Fatalf("项目成员保存错误:%+v", member) + } + expiresAt := time.Now().UTC().Add(time.Hour) + token, secret, err := store.CreateToken("ssl", "客户端令牌", RoleViewer, &expiresAt) + if err != nil { + t.Fatal(err) + } + if secret == "" || token.SecretHash != "" || token.Prefix == "" { + t.Fatalf("项目令牌返回错误:%+v,明文长度:%d", token, len(secret)) + } + matched, ok := store.AuthenticateToken("ssl", secret) + if !ok || matched.ID != token.ID { + t.Fatalf("项目令牌认证失败:%+v,%v", matched, ok) + } + if _, ok := store.AuthenticateToken("smail", secret); ok { + t.Fatal("项目令牌不应访问其他项目") + } + + reloaded, err := New(path) + if err != nil { + t.Fatal(err) + } + if len(reloaded.ListMembers("ssl")) != 1 || len(reloaded.ListTokens("ssl")) != 1 { + t.Fatal("项目权限数据重新加载失败") + } + if _, ok := reloaded.AuthenticateToken("ssl", secret); !ok { + t.Fatal("重新加载后项目令牌认证失败") + } +} + +func TestStoreRevokesTokenAndUpdatesMember(t *testing.T) { + store, err := New(filepath.Join(t.TempDir(), "access.json")) + if err != nil { + t.Fatal(err) + } + if _, err := store.AddMember("ssl", "user-1", RoleViewer); err != nil { + t.Fatal(err) + } + if _, err := store.UpdateMember("ssl", "user-1", RoleAdmin); err != nil { + t.Fatal(err) + } + member, ok := store.GetMember("ssl", "user-1") + if !ok || member.Role != RoleAdmin { + t.Fatalf("项目成员更新错误:%+v,%v", member, ok) + } + _, secret, err := store.CreateToken("ssl", "令牌", RoleDeveloper, nil) + if err != nil { + t.Fatal(err) + } + tokens := store.ListTokens("ssl") + if len(tokens) != 1 { + t.Fatal("项目令牌列表数量错误") + } + if _, err := store.RevokeToken("ssl", tokens[0].ID); err != nil { + t.Fatal(err) + } + if _, ok := store.AuthenticateToken("ssl", secret); ok { + t.Fatal("已吊销项目令牌仍可认证") + } +} + +func TestStoreKeepsLastProjectAdmin(t *testing.T) { + store, err := New(filepath.Join(t.TempDir(), "access.json")) + if err != nil { + t.Fatal(err) + } + if _, err := store.AddMember("ssl", "admin-1", RoleAdmin); err != nil { + t.Fatal(err) + } + if _, err := store.UpdateMember("ssl", "admin-1", RoleViewer); err == nil { + t.Fatal("demoting the last project admin should fail") + } + if err := store.DeleteMember("ssl", "admin-1"); err == nil { + t.Fatal("deleting the last project admin should fail") + } + if _, err := store.AddMember("ssl", "admin-2", RoleAdmin); err != nil { + t.Fatal(err) + } + if err := store.DeleteMember("ssl", "admin-2"); err != nil { + t.Fatal(err) + } + if _, err := store.UpdateMember("ssl", "admin-1", RoleViewer); err == nil { + t.Fatal("demoting the last project admin should fail") + } +} diff --git a/internal/httpapi/packages.go b/internal/httpapi/packages.go index fc34048..0978ab2 100644 --- a/internal/httpapi/packages.go +++ b/internal/httpapi/packages.go @@ -56,6 +56,12 @@ func (s *Server) packageList(writer http.ResponseWriter, request *http.Request) func (s *Server) upload(writer http.ResponseWriter, request *http.Request) { uploader, authorized := s.uploadUser(request) + if s.projectScope != "" { + if projectUploader, projectAuthorized := s.projectUploadUser(request, s.projectScope); projectAuthorized { + uploader = projectUploader + authorized = true + } + } if !authorized { if _, loggedIn := s.authenticatedUser(request); loggedIn { writeError(writer, http.StatusForbidden, "\u9700\u8981\u5f00\u53d1\u8005\u6216\u7ba1\u7406\u5458\u6743\u9650") diff --git a/internal/httpapi/projects.go b/internal/httpapi/projects.go index 49db436..acc1f76 100644 --- a/internal/httpapi/projects.go +++ b/internal/httpapi/projects.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" + "scout/internal/access" "scout/internal/registry" "scout/internal/users" ) @@ -132,6 +133,14 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request) writeError(writer, http.StatusForbidden, "项目当前已停用") return } + if len(parts) >= 2 && parts[1] == "members" { + s.projectMembersRoute(writer, request, projectID, parts[2:]) + return + } + if len(parts) >= 2 && parts[1] == "tokens" { + s.projectTokensRoute(writer, request, projectID, parts[2:]) + return + } store, ok := s.Projects.Store(projectID) if !ok { writeError(writer, http.StatusNotFound, "项目存储不存在") @@ -140,14 +149,23 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request) scoped := s.scopedRegistry(store, projectID) if parts[1] == "packages" { if len(parts) == 2 && request.Method == http.MethodGet { + if !s.requireProjectAccess(writer, request, projectID, access.RoleViewer) { + return + } scoped.packageList(writer, scopedRequest(request, "/api/v1/packages")) return } if len(parts) >= 3 && parts[2] == "upload" && request.Method == http.MethodPost { + if !s.requireProjectAccess(writer, request, projectID, access.RoleDeveloper) { + return + } scoped.upload(writer, scopedRequest(request, "/api/v1/packages/upload")) return } if request.Method == http.MethodGet || request.Method == http.MethodHead { + if !s.requireProjectAccess(writer, request, projectID, access.RoleViewer) { + return + } rest := strings.Join(parts[1:], "/") scoped.packageRoute(writer, scopedRequest(request, "/api/v1/"+rest)) return @@ -158,6 +176,9 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request) return } if len(parts) == 3 && parts[1] == "updates" && parts[2] == "stream" && request.Method == http.MethodGet { + if !s.requireProjectAccess(writer, request, projectID, access.RoleViewer) { + return + } scoped.stream(writer, scopedRequest(request, "/api/v1/updates/stream")) return } @@ -168,6 +189,7 @@ func (s *Server) scopedRegistry(store *registry.Registry, projectID string) *Ser return &Server{ Registry: store, Projects: s.Projects, + Access: s.Access, DeveloperToken: s.DeveloperToken, MaxUploadBytes: s.MaxUploadBytes, Users: s.Users, diff --git a/internal/httpapi/projects_access.go b/internal/httpapi/projects_access.go new file mode 100644 index 0000000..774a4fe --- /dev/null +++ b/internal/httpapi/projects_access.go @@ -0,0 +1,228 @@ +package httpapi + +import ( + "net/http" + "strings" + "time" + + "scout/internal/access" + "scout/internal/users" +) + +func (s *Server) requireProjectAccess(writer http.ResponseWriter, request *http.Request, projectID, required string) bool { + if s.Access == nil || !s.Access.HasAccessControl(projectID) { + if required == access.RoleViewer { + return true + } + if _, ok := s.uploadUser(request); ok { + return true + } + if _, loggedIn := s.authenticatedUser(request); loggedIn { + writeError(writer, http.StatusForbidden, "需要项目开发者或管理员权限") + } else { + writeError(writer, http.StatusUnauthorized, "请先登录项目开发者账户") + } + return false + } + if user, ok := s.authenticatedUser(request); ok && user.Role == users.RoleAdmin { + return true + } + if tokenValue := projectTokenValue(request); tokenValue != "" { + if token, ok := s.Access.AuthenticateToken(projectID, tokenValue); ok { + if access.Allows(token.Role, required) { + return true + } + writeError(writer, http.StatusForbidden, "项目令牌权限不足") + return false + } + } + if user, ok := s.authenticatedUser(request); ok { + member, memberOK := s.Access.GetMember(projectID, user.ID) + if memberOK && access.Allows(member.Role, required) { + return true + } + writeError(writer, http.StatusForbidden, "没有执行此项目操作的权限") + return false + } + writeError(writer, http.StatusUnauthorized, "请先登录或提供项目令牌") + return false +} + +func (s *Server) projectUploadUser(request *http.Request, projectID string) (users.User, bool) { + if s.Access != nil { + if tokenValue := projectTokenValue(request); tokenValue != "" { + if token, ok := s.Access.AuthenticateToken(projectID, tokenValue); ok && access.Allows(token.Role, access.RoleDeveloper) { + return users.User{ + ID: "project-token:" + token.ID, + Name: "项目令牌", + Role: users.RoleDeveloper, + Active: true, + }, true + } + } + } + if user, ok := s.authenticatedUser(request); ok { + if user.Role == users.RoleAdmin { + return user, true + } + if s.Access != nil { + if member, memberOK := s.Access.GetMember(projectID, user.ID); memberOK && access.Allows(member.Role, access.RoleDeveloper) { + return user, true + } + } + } + return s.uploadUser(request) +} + +func projectTokenValue(request *http.Request) string { + if value := strings.TrimSpace(request.Header.Get("X-Project-Token")); value != "" { + return value + } + value := strings.TrimSpace(request.Header.Get("Authorization")) + if strings.HasPrefix(value, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) + } + return "" +} + +func (s *Server) requireProjectAdmin(writer http.ResponseWriter, request *http.Request, projectID string) bool { + if s.Access == nil || !s.Access.HasAccessControl(projectID) { + return s.requireRole(writer, request, users.RoleAdmin) + } + if user, ok := s.authenticatedUser(request); ok && user.Role == users.RoleAdmin { + return true + } + if user, ok := s.authenticatedUser(request); ok { + member, memberOK := s.Access.GetMember(projectID, user.ID) + if memberOK && member.Role == access.RoleAdmin { + return true + } + writeError(writer, http.StatusForbidden, "没有管理此项目的权限") + return false + } + writeError(writer, http.StatusUnauthorized, "请先登录项目管理员账户") + return false +} + +func (s *Server) projectMembersRoute(writer http.ResponseWriter, request *http.Request, projectID string, rest []string) { + if s.Access == nil { + writeError(writer, http.StatusServiceUnavailable, "项目权限管理尚未启用") + return + } + if !s.requireProjectAdmin(writer, request, projectID) { + return + } + if len(rest) == 0 && request.Method == http.MethodGet { + writeJSON(writer, http.StatusOK, map[string]any{"members": s.Access.ListMembers(projectID)}) + return + } + if len(rest) == 0 && request.Method == http.MethodPost { + var input struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + if err := decodeJSON(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + if s.Users == nil { + writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用") + return + } + if _, ok := s.Users.Get(strings.TrimSpace(input.UserID)); !ok { + writeError(writer, http.StatusNotFound, "用户不存在") + return + } + member, err := s.Access.AddMember(projectID, input.UserID, input.Role) + if err != nil { + status := http.StatusBadRequest + if strings.Contains(err.Error(), "已经存在") { + status = http.StatusConflict + } + writeError(writer, status, err.Error()) + return + } + writeJSON(writer, http.StatusCreated, member) + return + } + if len(rest) == 1 && request.Method == http.MethodPatch { + var input struct { + Role string `json:"role"` + } + if err := decodeJSON(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + member, err := s.Access.UpdateMember(projectID, rest[0], input.Role) + if err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + writeJSON(writer, http.StatusOK, member) + return + } + if len(rest) == 1 && request.Method == http.MethodDelete { + if err := s.Access.DeleteMember(projectID, rest[0]); err != nil { + writeError(writer, http.StatusNotFound, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) + return + } + writeError(writer, http.StatusNotFound, "接口不存在") +} + +func (s *Server) projectTokensRoute(writer http.ResponseWriter, request *http.Request, projectID string, rest []string) { + if s.Access == nil { + writeError(writer, http.StatusServiceUnavailable, "项目权限管理尚未启用") + return + } + if !s.requireProjectAdmin(writer, request, projectID) { + return + } + if len(rest) == 0 && request.Method == http.MethodGet { + writeJSON(writer, http.StatusOK, map[string]any{"tokens": s.Access.ListTokens(projectID)}) + return + } + if len(rest) == 0 && request.Method == http.MethodPost { + var input struct { + Name string `json:"name"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` + } + if err := decodeJSON(request, &input); err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + role := strings.TrimSpace(input.Role) + if role == "" { + role = access.RoleViewer + } + var expiresAt *time.Time + if strings.TrimSpace(input.ExpiresAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(input.ExpiresAt)) + if err != nil || !parsed.After(time.Now().UTC()) { + writeError(writer, http.StatusBadRequest, "令牌过期时间必须是未来的 RFC3339 时间") + return + } + expiresAt = &parsed + } + token, secret, err := s.Access.CreateToken(projectID, input.Name, role, expiresAt) + if err != nil { + writeError(writer, http.StatusBadRequest, err.Error()) + return + } + writeJSON(writer, http.StatusCreated, map[string]any{"token": token, "secret": secret}) + return + } + if len(rest) == 2 && rest[1] == "revoke" && request.Method == http.MethodPost { + token, err := s.Access.RevokeToken(projectID, rest[0]) + if err != nil { + writeError(writer, http.StatusNotFound, err.Error()) + return + } + writeJSON(writer, http.StatusOK, token) + return + } + writeError(writer, http.StatusNotFound, "接口不存在") +} diff --git a/internal/httpapi/projects_access_test.go b/internal/httpapi/projects_access_test.go new file mode 100644 index 0000000..63a585c --- /dev/null +++ b/internal/httpapi/projects_access_test.go @@ -0,0 +1,218 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "scout/internal/access" + "scout/internal/registry" + "scout/internal/users" +) + +func TestProjectMembersAndTokensAuthorizeScopedOperations(t *testing.T) { + root := t.TempDir() + legacy, err := registry.New(root) + if err != nil { + t.Fatal(err) + } + projects, err := registry.NewProjectRegistry(root) + if err != nil { + t.Fatal(err) + } + if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil { + t.Fatal(err) + } + accessStore, err := access.New(filepath.Join(root, "project-access.json")) + if err != nil { + t.Fatal(err) + } + userStore, err := users.New(filepath.Join(root, "users.json")) + if err != nil { + t.Fatal(err) + } + admin, err := userStore.EnsureAdmin("admin@example.com", "管理员", "admin-pass-123") + if err != nil { + t.Fatal(err) + } + viewer, err := userStore.Register("viewer@example.com", "查看者", "viewer-pass-123") + if err != nil { + t.Fatal(err) + } + adminSession, _, err := userStore.CreateSession(admin.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + viewerSession, _, err := userStore.CreateSession(viewer.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer((&Server{Registry: legacy, Projects: projects, Access: accessStore, Users: userStore}).Handler()) + defer server.Close() + + memberResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/members", adminSession, map[string]string{ + "user_id": viewer.ID, + "role": access.RoleViewer, + }) + if memberResponse.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(memberResponse.Body) + t.Fatalf("添加项目成员失败:%d %s", memberResponse.StatusCode, body) + } + memberResponse.Body.Close() + + publicResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", nil) + if publicResponse.StatusCode != http.StatusUnauthorized { + t.Fatalf("启用项目权限后匿名读取状态错误:%d", publicResponse.StatusCode) + } + publicResponse.Body.Close() + + viewerResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", map[string]string{"Authorization": "Bearer " + viewerSession}) + if viewerResponse.StatusCode != http.StatusOK { + t.Fatalf("项目查看成员读取状态错误:%d", viewerResponse.StatusCode) + } + viewerResponse.Body.Close() + + uploadResponse := projectMultipartRequest(t, server.URL+"/api/v1/projects/ssl/packages/upload", viewerSession, "secure", "1.0.0", "secure.pkg", "secure-content") + if uploadResponse.StatusCode != http.StatusForbidden { + t.Fatalf("项目查看成员上传状态错误:%d", uploadResponse.StatusCode) + } + uploadResponse.Body.Close() + + tokenResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/tokens", adminSession, map[string]string{ + "name": "客户端只读令牌", + "role": access.RoleViewer, + }) + if tokenResponse.StatusCode != http.StatusCreated { + t.Fatalf("创建项目令牌失败:%d", tokenResponse.StatusCode) + } + var created struct { + Token access.ProjectToken `json:"token"` + Secret string `json:"secret"` + } + if err := json.NewDecoder(tokenResponse.Body).Decode(&created); err != nil { + t.Fatal(err) + } + tokenResponse.Body.Close() + if created.Secret == "" || created.Token.SecretHash != "" { + t.Fatal("项目令牌响应不符合只显示一次明文的要求") + } + + tokenRead := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/packages", map[string]string{"X-Project-Token": created.Secret}) + if tokenRead.StatusCode != http.StatusOK { + t.Fatalf("项目只读令牌读取状态错误:%d", tokenRead.StatusCode) + } + tokenRead.Body.Close() + developerTokenResponse := jsonRequest(t, http.MethodPost, server.URL+"/api/v1/projects/ssl/tokens", adminSession, map[string]string{ + "name": "客户端开发令牌", + "role": access.RoleDeveloper, + }) + if developerTokenResponse.StatusCode != http.StatusCreated { + t.Fatalf("创建项目开发令牌失败:%d", developerTokenResponse.StatusCode) + } + var developerCreated struct { + Token access.ProjectToken `json:"token"` + Secret string `json:"secret"` + } + if err := json.NewDecoder(developerTokenResponse.Body).Decode(&developerCreated); err != nil { + t.Fatal(err) + } + developerTokenResponse.Body.Close() + developerUpload := projectMultipartRequestWithHeader(t, server.URL+"/api/v1/projects/ssl/packages/upload", "X-Project-Token", developerCreated.Secret, "token-upload", "1.0.0", "token-upload.pkg", "token-upload-content") + if developerUpload.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(developerUpload.Body) + developerUpload.Body.Close() + t.Fatalf("项目开发令牌上传失败:%d %s", developerUpload.StatusCode, body) + } + developerUpload.Body.Close() + crossProject := getWithHeaders(t, server.URL+"/api/v1/projects/smail/packages", map[string]string{"X-Project-Token": created.Secret}) + if crossProject.StatusCode != http.StatusNotFound { + crossProject.Body.Close() + t.Fatalf("不存在项目的令牌请求状态错误:%d", crossProject.StatusCode) + } + crossProject.Body.Close() + + tokensResponse := getWithHeaders(t, server.URL+"/api/v1/projects/ssl/tokens", map[string]string{"Authorization": "Bearer " + adminSession}) + if tokensResponse.StatusCode != http.StatusOK { + t.Fatalf("查询项目令牌失败:%d", tokensResponse.StatusCode) + } + data, _ := io.ReadAll(tokensResponse.Body) + tokensResponse.Body.Close() + if bytes.Contains(data, []byte(created.Secret)) { + t.Fatal("项目令牌明文不应出现在列表响应") + } +} + +func jsonRequest(t *testing.T, method, target, token string, value any) *http.Response { + t.Helper() + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func getWithHeaders(t *testing.T, target string, headers map[string]string) *http.Response { + t.Helper() + request, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + t.Fatal(err) + } + for key, value := range headers { + request.Header.Set(key, value) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} + +func projectMultipartRequest(t *testing.T, target, token, name, version, fileName, content string) *http.Response { + return projectMultipartRequestWithHeader(t, target, "Authorization", "Bearer "+token, name, version, fileName, content) +} + +func projectMultipartRequestWithHeader(t *testing.T, target, headerName, headerValue, name, version, fileName, content string) *http.Response { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("name", name) + _ = writer.WriteField("version", version) + _ = writer.WriteField("platform", "linux") + _ = writer.WriteField("architecture", "amd64") + file, err := writer.CreateFormFile("file", fileName) + if err != nil { + t.Fatal(err) + } + _, _ = file.Write([]byte(content)) + _ = writer.Close() + request, err := http.NewRequest(http.MethodPost, target, body) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + request.Header.Set(headerName, headerValue) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + return response +} diff --git a/internal/httpapi/security.go b/internal/httpapi/security.go index 8d78505..8ac391e 100644 --- a/internal/httpapi/security.go +++ b/internal/httpapi/security.go @@ -113,8 +113,8 @@ func (s *Server) cors(next http.Handler) http.Handler { writeError(writer, http.StatusForbidden, "访问来源不在允许列表中") return } - writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token") - writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS") + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token, X-Project-Token") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") writer.Header().Set("Access-Control-Max-Age", "600") if request.Method == http.MethodOptions { writer.WriteHeader(http.StatusNoContent) diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index 64e504a..55161ca 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -21,6 +22,13 @@ func TestSecurityHeadersAndCORS(t *testing.T) { if response.Header().Get("X-Content-Type-Options") != "nosniff" { t.Fatal("安全响应头缺失") } + allowHeaders := response.Header().Get("Access-Control-Allow-Headers") + if !strings.Contains(allowHeaders, "X-Project-Token") { + t.Fatalf("项目令牌请求头未加入跨域允许列表:%s", allowHeaders) + } + if !strings.Contains(response.Header().Get("Access-Control-Allow-Methods"), "DELETE") { + t.Fatal("项目成员删除方法未加入跨域允许列表") + } } func TestHostGuardRejectsUnknownHost(t *testing.T) { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index fda9dce..92a383e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "scout/internal/access" "scout/internal/notify" "scout/internal/registry" "scout/internal/users" @@ -23,6 +24,7 @@ import ( type Server struct { Registry *registry.Registry Projects *registry.ProjectRegistry + Access *access.Store DeveloperToken string MaxUploadBytes int64 Users *users.Store @@ -546,8 +548,8 @@ func writeError(writer http.ResponseWriter, status int, message string) { func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Access-Control-Allow-Origin", "*") - writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token") - writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS") + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token, X-Project-Token") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") if request.Method == http.MethodOptions { writer.WriteHeader(http.StatusNoContent) return diff --git a/pkg/client/client.go b/pkg/client/client.go index e50dd92..a87047e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -69,10 +69,11 @@ type UploadRequest struct { type Option func(*Client) type Client struct { - baseURL string - httpClient *http.Client - token string - project string + baseURL string + httpClient *http.Client + token string + projectToken string + project string } func New(baseURL string, options ...Option) (*Client, error) { @@ -109,6 +110,12 @@ func WithDeveloperToken(token string) Option { } } +func WithProjectToken(token string) Option { + return func(client *Client) { + client.projectToken = strings.TrimSpace(token) + } +} + func WithProject(project string) Option { return func(client *Client) { client.project = strings.TrimSpace(project) @@ -397,6 +404,9 @@ func (c *Client) newRequest(ctx context.Context, method, path string, body io.Re if c.token != "" { request.Header.Set("Authorization", "Bearer "+c.token) } + if c.projectToken != "" { + request.Header.Set("X-Project-Token", c.projectToken) + } return request, nil } diff --git a/pkg/client/projects.go b/pkg/client/projects.go index d0be589..73bd4ae 100644 --- a/pkg/client/projects.go +++ b/pkg/client/projects.go @@ -17,6 +17,30 @@ type Project struct { UpdatedAt string `json:"updated_at"` } +type ProjectMember struct { + ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` + UpdatedAt string `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"` + CreatedAt string `json:"created_at"` + ExpiresAt *string `json:"expires_at,omitempty"` + RevokedAt *string `json:"revoked_at,omitempty"` +} + +type CreatedProjectToken struct { + Token ProjectToken `json:"token"` + Secret string `json:"secret"` +} + func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { var response struct { Projects []Project `json:"projects"` @@ -68,6 +92,86 @@ func (c *Client) UpdateProject(ctx context.Context, project Project) (Project, e } return result, nil } + +func (c *Client) ListProjectMembers(ctx context.Context) ([]ProjectMember, error) { + var response struct { + Members []ProjectMember `json:"members"` + } + if err := c.getJSON(ctx, c.projectPath("members"), &response); err != nil { + return nil, err + } + return response.Members, nil +} + +func (c *Client) AddProjectMember(ctx context.Context, userID, role string) (ProjectMember, error) { + var result ProjectMember + if err := c.postJSON(ctx, c.projectPath("members"), map[string]string{"user_id": userID, "role": role}, &result); err != nil { + return ProjectMember{}, err + } + return result, nil +} + +func (c *Client) UpdateProjectMember(ctx context.Context, userID, role string) (ProjectMember, error) { + var result ProjectMember + path := c.projectPath("members") + "/" + url.PathEscape(strings.TrimSpace(userID)) + if err := c.patchJSON(ctx, path, map[string]string{"role": role}, &result); err != nil { + return ProjectMember{}, err + } + return result, nil +} + +func (c *Client) DeleteProjectMember(ctx context.Context, userID string) error { + path := c.projectPath("members") + "/" + url.PathEscape(strings.TrimSpace(userID)) + request, err := c.newRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + response, err := c.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + return checkResponse(response) +} + +func (c *Client) ListProjectTokens(ctx context.Context) ([]ProjectToken, error) { + var response struct { + Tokens []ProjectToken `json:"tokens"` + } + if err := c.getJSON(ctx, c.projectPath("tokens"), &response); err != nil { + return nil, err + } + return response.Tokens, nil +} + +func (c *Client) CreateProjectToken(ctx context.Context, name, role string, expiresAt *string) (CreatedProjectToken, error) { + var result CreatedProjectToken + input := map[string]string{"name": name, "role": role} + if expiresAt != nil { + input["expires_at"] = *expiresAt + } + if err := c.postJSON(ctx, c.projectPath("tokens"), input, &result); err != nil { + return CreatedProjectToken{}, err + } + return result, nil +} + +func (c *Client) RevokeProjectToken(ctx context.Context, tokenID string) (ProjectToken, error) { + var result ProjectToken + path := c.projectPath("tokens") + "/" + url.PathEscape(strings.TrimSpace(tokenID)) + "/revoke" + if err := c.postJSON(ctx, path, map[string]string{}, &result); err != nil { + return ProjectToken{}, err + } + return result, nil +} + +func (c *Client) projectPath(resource string) string { + if c.project == "" { + return "/api/v1/projects/" + url.PathEscape(resource) + } + return "/api/v1/projects/" + url.PathEscape(c.project) + "/" + url.PathEscape(resource) +} + func (c *Client) ProjectHealth(ctx context.Context) error { request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil) if err != nil { diff --git a/pkg/client/projects_test.go b/pkg/client/projects_test.go index 17213c2..64d4169 100644 --- a/pkg/client/projects_test.go +++ b/pkg/client/projects_test.go @@ -87,3 +87,68 @@ func TestNewProjectRejectsUnsafeProjectID(t *testing.T) { t.Fatal("不安全项目标识应该失败") } } + +func TestProjectClientAccessMethodsUseProjectToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer session-token" { + t.Fatalf("session authorization header missing: %q", request.Header.Get("Authorization")) + } + if request.Header.Get("X-Project-Token") != "sct_project-token" { + t.Fatalf("project token header missing: %q", request.Header.Get("X-Project-Token")) + } + writer.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/projects/ssl/members": + _, _ = writer.Write([]byte(`{"members":[{"project_id":"ssl","user_id":"user-1","role":"project_viewer"}]}`)) + case request.Method == http.MethodPost && request.URL.Path == "/api/v1/projects/ssl/members": + _, _ = writer.Write([]byte(`{"project_id":"ssl","user_id":"user-2","role":"project_developer"}`)) + case request.Method == http.MethodPatch && request.URL.Path == "/api/v1/projects/ssl/members/user-2": + _, _ = writer.Write([]byte(`{"project_id":"ssl","user_id":"user-2","role":"project_admin"}`)) + case request.Method == http.MethodDelete && request.URL.Path == "/api/v1/projects/ssl/members/user-2": + writer.WriteHeader(http.StatusNoContent) + return + case request.Method == http.MethodGet && request.URL.Path == "/api/v1/projects/ssl/tokens": + _, _ = writer.Write([]byte(`{"tokens":[{"id":"tok-1","project_id":"ssl","name":"client","role":"project_viewer"}]}`)) + case request.Method == http.MethodPost && request.URL.Path == "/api/v1/projects/ssl/tokens": + _, _ = writer.Write([]byte(`{"token":{"id":"tok-2","project_id":"ssl","name":"writer","role":"project_developer"},"secret":"sct_created"}`)) + case request.Method == http.MethodPost && request.URL.Path == "/api/v1/projects/ssl/tokens/tok-2/revoke": + _, _ = writer.Write([]byte(`{"id":"tok-2","project_id":"ssl","name":"writer","role":"project_developer","revoked_at":"2026-08-31T00:00:00Z"}`)) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + api, err := NewProject(server.URL, "ssl", WithDeveloperToken("session-token"), WithProjectToken("sct_project-token")) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + members, err := api.ListProjectMembers(ctx) + if err != nil || len(members) != 1 || members[0].UserID != "user-1" { + t.Fatalf("list members failed: %+v, %v", members, err) + } + member, err := api.AddProjectMember(ctx, "user-2", "project_developer") + if err != nil || member.UserID != "user-2" { + t.Fatalf("add member failed: %+v, %v", member, err) + } + member, err = api.UpdateProjectMember(ctx, "user-2", "project_admin") + if err != nil || member.Role != "project_admin" { + t.Fatalf("update member failed: %+v, %v", member, err) + } + if err := api.DeleteProjectMember(ctx, "user-2"); err != nil { + t.Fatalf("delete member failed: %v", err) + } + tokens, err := api.ListProjectTokens(ctx) + if err != nil || len(tokens) != 1 || tokens[0].ID != "tok-1" { + t.Fatalf("list tokens failed: %+v, %v", tokens, err) + } + created, err := api.CreateProjectToken(ctx, "writer", "project_developer", nil) + if err != nil || created.Secret != "sct_created" || created.Token.ID != "tok-2" { + t.Fatalf("create token failed: %+v, %v", created, err) + } + revoked, err := api.RevokeProjectToken(ctx, "tok-2") + if err != nil || revoked.RevokedAt == nil { + t.Fatalf("revoke token failed: %+v, %v", revoked, err) + } +} diff --git a/使用说明.md b/使用说明.md index 4b5c4d9..6a4704f 100644 --- a/使用说明.md +++ b/使用说明.md @@ -163,10 +163,17 @@ Scout 服务端是独立发布中心,不需要修改 SSL、Smail 或后续接 | POST | `/api/v1/projects` | 创建项目 | 管理员 | | GET | `/api/v1/projects/{project}` | 查看项目配置 | 无 | | PATCH | `/api/v1/projects/{project}` | 修改项目名称、说明或状态 | 管理员 | -| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 无 | -| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 开发者/管理员会话 | -| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 无 | -| GET | `/api/v1/projects/{project}/updates/stream` | 监听项目更新 | 无 | +| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 项目查看者令牌或成员会话;未启用项目权限时公开 | +| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 项目开发者令牌或成员会话 | +| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 项目查看者令牌或成员会话;未启用项目权限时公开 | +| GET | `/api/v1/projects/{project}/updates/stream` | 监听项目更新 | 项目查看者令牌或成员会话;未启用项目权限时公开 | +| GET | `/api/v1/projects/{project}/members` | 查看项目成员 | 项目管理员成员会话或全局管理员会话 | +| POST | `/api/v1/projects/{project}/members` | 添加项目成员 | 项目管理员成员会话或全局管理员会话 | +| PATCH | `/api/v1/projects/{project}/members/{userID}` | 修改项目成员角色 | 项目管理员成员会话或全局管理员会话 | +| DELETE | `/api/v1/projects/{project}/members/{userID}` | 删除项目成员 | 项目管理员成员会话或全局管理员会话 | +| GET | `/api/v1/projects/{project}/tokens` | 查看项目令牌元数据 | 项目管理员成员会话或全局管理员会话 | +| POST | `/api/v1/projects/{project}/tokens` | 创建项目令牌 | 项目管理员成员会话或全局管理员会话 | +| POST | `/api/v1/projects/{project}/tokens/{tokenID}/revoke` | 吊销项目令牌 | 项目管理员成员会话或全局管理员会话 | 项目状态为 `disabled` 时,项目配置仍可查询,但包列表、上传、下载和更新通知会被拒绝。项目目录位于: @@ -174,6 +181,17 @@ Scout 服务端是独立发布中心,不需要修改 SSL、Smail 或后续接 data/projects/{project}/ ``` +项目权限数据位于 `data/project-access.json`。项目令牌创建后只返回一次 `secret` 明文,服务端只保存哈希;调用包列表、下载和更新流时可使用 `X-Project-Token` 请求头,也可使用 `Authorization: Bearer <项目令牌>`。项目令牌只能访问创建时绑定的项目。 + +项目角色如下: + +- `project_viewer`:查看包、下载包、监听更新。 +- `project_developer`:包含查看权限,并允许上传包。 +- `project_admin`:管理项目成员和项目令牌;项目令牌本身不能授予此角色。 + +当项目尚未添加成员或令牌时,读取接口保持兼容公开访问;首次添加成员或令牌后,该项目的包读取、下载和更新流会启用认证。这样可以在不改造 SSL、Smail 的情况下逐项目迁移到安全模式。 + + 接入 SSL、Smail 或后续项目时,只需在发布中心创建对应项目,再让宿主应用使用项目级客户端: ```go @@ -285,6 +303,7 @@ for update := range updates { | `New` | 创建客户端 | | `WithDeveloperToken` | 配置兼容开发者令牌 | | `WithSessionToken` | 配置登录会话令牌 | +| `WithProjectToken` | 配置绑定当前项目的访问令牌 | | `Register` | 注册用户 | | `Login` | 登录并自动保存会话令牌 | | `Me` | 获取当前登录用户 | @@ -298,6 +317,13 @@ for update := range updates { | `Download` | 下载并校验 SHA-256 | | `WatchUpdates` | 监听 SSE 更新通知 | | `Upload` | 使用兼容令牌或会话上传包 | +| `ListProjectMembers` | 查看当前项目成员 | +| `AddProjectMember` | 添加当前项目成员 | +| `UpdateProjectMember` | 修改当前项目成员角色 | +| `DeleteProjectMember` | 删除当前项目成员 | +| `ListProjectTokens` | 查看当前项目令牌元数据 | +| `CreateProjectToken` | 创建项目令牌并获取一次性明文 | +| `RevokeProjectToken` | 吊销当前项目令牌 | ## 跨平台客户端集成