完善项目权限与客户端令牌管理
This commit is contained in:
parent
dafa92c811
commit
7d0870dda5
@ -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
399
internal/access/store.go
Normal 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[:])
|
||||
}
|
||||
104
internal/access/store_test.go
Normal file
104
internal/access/store_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
228
internal/httpapi/projects_access.go
Normal file
228
internal/httpapi/projects_access.go
Normal 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, "接口不存在")
|
||||
}
|
||||
218
internal/httpapi/projects_access_test.go
Normal file
218
internal/httpapi/projects_access_test.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
34
使用说明.md
34
使用说明.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` | 吊销当前项目令牌 |
|
||||
|
||||
## 跨平台客户端集成
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user