完善项目权限与客户端令牌管理

This commit is contained in:
Scout开发者 2026-08-31 21:19:15 +08:00
parent dafa92c811
commit 7d0870dda5
14 changed files with 1210 additions and 12 deletions

View File

@ -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,

399
internal/access/store.go Normal file
View File

@ -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[:])
}

View File

@ -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")
}
}

View File

@ -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")

View File

@ -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,

View File

@ -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, "接口不存在")
}

View File

@ -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
}

View File

@ -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)

View File

@ -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) {

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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` | 吊销当前项目令牌 |
## 跨平台客户端集成