完善项目权限与客户端令牌管理
This commit is contained in:
parent
dafa92c811
commit
7d0870dda5
@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"scout/internal/access"
|
||||||
"scout/internal/config"
|
"scout/internal/config"
|
||||||
"scout/internal/httpapi"
|
"scout/internal/httpapi"
|
||||||
"scout/internal/notify"
|
"scout/internal/notify"
|
||||||
@ -32,6 +33,10 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal(err)
|
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"))
|
userStore, err := users.New(filepath.Join(cfg.DataDir, "users.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal(err)
|
logger.Fatal(err)
|
||||||
@ -45,6 +50,7 @@ func main() {
|
|||||||
api := &httpapi.Server{
|
api := &httpapi.Server{
|
||||||
Registry: store,
|
Registry: store,
|
||||||
Projects: projectStore,
|
Projects: projectStore,
|
||||||
|
Access: accessStore,
|
||||||
DeveloperToken: cfg.DeveloperToken,
|
DeveloperToken: cfg.DeveloperToken,
|
||||||
AllowLegacyToken: cfg.AllowLegacyToken,
|
AllowLegacyToken: cfg.AllowLegacyToken,
|
||||||
MaxUploadBytes: cfg.MaxUploadBytes,
|
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) {
|
func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
|
||||||
uploader, authorized := s.uploadUser(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 !authorized {
|
||||||
if _, loggedIn := s.authenticatedUser(request); loggedIn {
|
if _, loggedIn := s.authenticatedUser(request); loggedIn {
|
||||||
writeError(writer, http.StatusForbidden, "\u9700\u8981\u5f00\u53d1\u8005\u6216\u7ba1\u7406\u5458\u6743\u9650")
|
writeError(writer, http.StatusForbidden, "\u9700\u8981\u5f00\u53d1\u8005\u6216\u7ba1\u7406\u5458\u6743\u9650")
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"scout/internal/access"
|
||||||
"scout/internal/registry"
|
"scout/internal/registry"
|
||||||
"scout/internal/users"
|
"scout/internal/users"
|
||||||
)
|
)
|
||||||
@ -132,6 +133,14 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
|||||||
writeError(writer, http.StatusForbidden, "项目当前已停用")
|
writeError(writer, http.StatusForbidden, "项目当前已停用")
|
||||||
return
|
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)
|
store, ok := s.Projects.Store(projectID)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(writer, http.StatusNotFound, "项目存储不存在")
|
writeError(writer, http.StatusNotFound, "项目存储不存在")
|
||||||
@ -140,14 +149,23 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
|||||||
scoped := s.scopedRegistry(store, projectID)
|
scoped := s.scopedRegistry(store, projectID)
|
||||||
if parts[1] == "packages" {
|
if parts[1] == "packages" {
|
||||||
if len(parts) == 2 && request.Method == http.MethodGet {
|
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"))
|
scoped.packageList(writer, scopedRequest(request, "/api/v1/packages"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(parts) >= 3 && parts[2] == "upload" && request.Method == http.MethodPost {
|
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"))
|
scoped.upload(writer, scopedRequest(request, "/api/v1/packages/upload"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Method == http.MethodGet || request.Method == http.MethodHead {
|
if request.Method == http.MethodGet || request.Method == http.MethodHead {
|
||||||
|
if !s.requireProjectAccess(writer, request, projectID, access.RoleViewer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
rest := strings.Join(parts[1:], "/")
|
rest := strings.Join(parts[1:], "/")
|
||||||
scoped.packageRoute(writer, scopedRequest(request, "/api/v1/"+rest))
|
scoped.packageRoute(writer, scopedRequest(request, "/api/v1/"+rest))
|
||||||
return
|
return
|
||||||
@ -158,6 +176,9 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(parts) == 3 && parts[1] == "updates" && parts[2] == "stream" && request.Method == http.MethodGet {
|
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"))
|
scoped.stream(writer, scopedRequest(request, "/api/v1/updates/stream"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -168,6 +189,7 @@ func (s *Server) scopedRegistry(store *registry.Registry, projectID string) *Ser
|
|||||||
return &Server{
|
return &Server{
|
||||||
Registry: store,
|
Registry: store,
|
||||||
Projects: s.Projects,
|
Projects: s.Projects,
|
||||||
|
Access: s.Access,
|
||||||
DeveloperToken: s.DeveloperToken,
|
DeveloperToken: s.DeveloperToken,
|
||||||
MaxUploadBytes: s.MaxUploadBytes,
|
MaxUploadBytes: s.MaxUploadBytes,
|
||||||
Users: s.Users,
|
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, "访问来源不在允许列表中")
|
writeError(writer, http.StatusForbidden, "访问来源不在允许列表中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token")
|
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, OPTIONS")
|
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||||
writer.Header().Set("Access-Control-Max-Age", "600")
|
writer.Header().Set("Access-Control-Max-Age", "600")
|
||||||
if request.Method == http.MethodOptions {
|
if request.Method == http.MethodOptions {
|
||||||
writer.WriteHeader(http.StatusNoContent)
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,6 +22,13 @@ func TestSecurityHeadersAndCORS(t *testing.T) {
|
|||||||
if response.Header().Get("X-Content-Type-Options") != "nosniff" {
|
if response.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||||
t.Fatal("安全响应头缺失")
|
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) {
|
func TestHostGuardRejectsUnknownHost(t *testing.T) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scout/internal/access"
|
||||||
"scout/internal/notify"
|
"scout/internal/notify"
|
||||||
"scout/internal/registry"
|
"scout/internal/registry"
|
||||||
"scout/internal/users"
|
"scout/internal/users"
|
||||||
@ -23,6 +24,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
Registry *registry.Registry
|
Registry *registry.Registry
|
||||||
Projects *registry.ProjectRegistry
|
Projects *registry.ProjectRegistry
|
||||||
|
Access *access.Store
|
||||||
DeveloperToken string
|
DeveloperToken string
|
||||||
MaxUploadBytes int64
|
MaxUploadBytes int64
|
||||||
Users *users.Store
|
Users *users.Store
|
||||||
@ -546,8 +548,8 @@ func writeError(writer http.ResponseWriter, status int, message string) {
|
|||||||
func cors(next http.Handler) http.Handler {
|
func cors(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
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-Headers", "Content-Type, Authorization, X-Developer-Token, X-Project-Token")
|
||||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
|
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||||
if request.Method == http.MethodOptions {
|
if request.Method == http.MethodOptions {
|
||||||
writer.WriteHeader(http.StatusNoContent)
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -69,10 +69,11 @@ type UploadRequest struct {
|
|||||||
type Option func(*Client)
|
type Option func(*Client)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
token string
|
token string
|
||||||
project string
|
projectToken string
|
||||||
|
project string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(baseURL string, options ...Option) (*Client, error) {
|
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 {
|
func WithProject(project string) Option {
|
||||||
return func(client *Client) {
|
return func(client *Client) {
|
||||||
client.project = strings.TrimSpace(project)
|
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 != "" {
|
if c.token != "" {
|
||||||
request.Header.Set("Authorization", "Bearer "+c.token)
|
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
}
|
}
|
||||||
|
if c.projectToken != "" {
|
||||||
|
request.Header.Set("X-Project-Token", c.projectToken)
|
||||||
|
}
|
||||||
return request, nil
|
return request, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,30 @@ type Project struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
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) {
|
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
|
||||||
var response struct {
|
var response struct {
|
||||||
Projects []Project `json:"projects"`
|
Projects []Project `json:"projects"`
|
||||||
@ -68,6 +92,86 @@ func (c *Client) UpdateProject(ctx context.Context, project Project) (Project, e
|
|||||||
}
|
}
|
||||||
return result, nil
|
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 {
|
func (c *Client) ProjectHealth(ctx context.Context) error {
|
||||||
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
|
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -87,3 +87,68 @@ func TestNewProjectRejectsUnsafeProjectID(t *testing.T) {
|
|||||||
t.Fatal("不安全项目标识应该失败")
|
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` | 创建项目 | 管理员 |
|
| POST | `/api/v1/projects` | 创建项目 | 管理员 |
|
||||||
| GET | `/api/v1/projects/{project}` | 查看项目配置 | 无 |
|
| GET | `/api/v1/projects/{project}` | 查看项目配置 | 无 |
|
||||||
| PATCH | `/api/v1/projects/{project}` | 修改项目名称、说明或状态 | 管理员 |
|
| PATCH | `/api/v1/projects/{project}` | 修改项目名称、说明或状态 | 管理员 |
|
||||||
| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 无 |
|
| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 项目查看者令牌或成员会话;未启用项目权限时公开 |
|
||||||
| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 开发者/管理员会话 |
|
| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 项目开发者令牌或成员会话 |
|
||||||
| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 无 |
|
| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 项目查看者令牌或成员会话;未启用项目权限时公开 |
|
||||||
| GET | `/api/v1/projects/{project}/updates/stream` | 监听项目更新 | 无 |
|
| 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` 时,项目配置仍可查询,但包列表、上传、下载和更新通知会被拒绝。项目目录位于:
|
项目状态为 `disabled` 时,项目配置仍可查询,但包列表、上传、下载和更新通知会被拒绝。项目目录位于:
|
||||||
|
|
||||||
@ -174,6 +181,17 @@ Scout 服务端是独立发布中心,不需要修改 SSL、Smail 或后续接
|
|||||||
data/projects/{project}/
|
data/projects/{project}/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
项目权限数据位于 `data/project-access.json`。项目令牌创建后只返回一次 `secret` 明文,服务端只保存哈希;调用包列表、下载和更新流时可使用 `X-Project-Token` 请求头,也可使用 `Authorization: Bearer <项目令牌>`。项目令牌只能访问创建时绑定的项目。
|
||||||
|
|
||||||
|
项目角色如下:
|
||||||
|
|
||||||
|
- `project_viewer`:查看包、下载包、监听更新。
|
||||||
|
- `project_developer`:包含查看权限,并允许上传包。
|
||||||
|
- `project_admin`:管理项目成员和项目令牌;项目令牌本身不能授予此角色。
|
||||||
|
|
||||||
|
当项目尚未添加成员或令牌时,读取接口保持兼容公开访问;首次添加成员或令牌后,该项目的包读取、下载和更新流会启用认证。这样可以在不改造 SSL、Smail 的情况下逐项目迁移到安全模式。
|
||||||
|
|
||||||
|
|
||||||
接入 SSL、Smail 或后续项目时,只需在发布中心创建对应项目,再让宿主应用使用项目级客户端:
|
接入 SSL、Smail 或后续项目时,只需在发布中心创建对应项目,再让宿主应用使用项目级客户端:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -285,6 +303,7 @@ for update := range updates {
|
|||||||
| `New` | 创建客户端 |
|
| `New` | 创建客户端 |
|
||||||
| `WithDeveloperToken` | 配置兼容开发者令牌 |
|
| `WithDeveloperToken` | 配置兼容开发者令牌 |
|
||||||
| `WithSessionToken` | 配置登录会话令牌 |
|
| `WithSessionToken` | 配置登录会话令牌 |
|
||||||
|
| `WithProjectToken` | 配置绑定当前项目的访问令牌 |
|
||||||
| `Register` | 注册用户 |
|
| `Register` | 注册用户 |
|
||||||
| `Login` | 登录并自动保存会话令牌 |
|
| `Login` | 登录并自动保存会话令牌 |
|
||||||
| `Me` | 获取当前登录用户 |
|
| `Me` | 获取当前登录用户 |
|
||||||
@ -298,6 +317,13 @@ for update := range updates {
|
|||||||
| `Download` | 下载并校验 SHA-256 |
|
| `Download` | 下载并校验 SHA-256 |
|
||||||
| `WatchUpdates` | 监听 SSE 更新通知 |
|
| `WatchUpdates` | 监听 SSE 更新通知 |
|
||||||
| `Upload` | 使用兼容令牌或会话上传包 |
|
| `Upload` | 使用兼容令牌或会话上传包 |
|
||||||
|
| `ListProjectMembers` | 查看当前项目成员 |
|
||||||
|
| `AddProjectMember` | 添加当前项目成员 |
|
||||||
|
| `UpdateProjectMember` | 修改当前项目成员角色 |
|
||||||
|
| `DeleteProjectMember` | 删除当前项目成员 |
|
||||||
|
| `ListProjectTokens` | 查看当前项目令牌元数据 |
|
||||||
|
| `CreateProjectToken` | 创建项目令牌并获取一次性明文 |
|
||||||
|
| `RevokeProjectToken` | 吊销当前项目令牌 |
|
||||||
|
|
||||||
## 跨平台客户端集成
|
## 跨平台客户端集成
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user