权限整改:完成 SSH 私钥项目归属隔离

为 SSH 私钥记录项目归属,限制服务器引用及私钥列表、新增、删除操作的项目权限,兼容历史默认项目数据并补充专项测试。
This commit is contained in:
xuans 2026-09-29 19:05:52 +08:00
parent 32d749a0c1
commit 56c88c3ec2
4 changed files with 100 additions and 8 deletions

View File

@ -873,11 +873,11 @@ func (s *Server) updateServer(c *gin.Context) {
c.JSON(http.StatusOK, item)
}
func (s *Server) validateSSHKeyReference(ctx context.Context, reference string) error {
func (s *Server) validateSSHKeyReference(ctx context.Context, reference, projectID string) error {
if s.sshKeys == nil {
return errors.New("SSH 私钥管理服务未配置")
}
_, err := s.sshKeys.ValidateReference(ctx, reference)
_, err := s.sshKeys.ValidateReferenceForProject(ctx, reference, projectID)
return err
}
@ -885,7 +885,7 @@ func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Se
item = item.Normalize()
cleanup := func() {}
if item.AuthMethod == deployment.SSHAuthPrivateKey {
if err := s.validateSSHKeyReference(ctx, item.PrivateKeyRef); err != nil {
if err := s.validateSSHKeyReference(ctx, item.PrivateKeyRef, item.ProjectID); err != nil {
return deployment.Server{}, cleanup, err
}
if existing != nil && item.PasswordRef == "" {
@ -915,7 +915,7 @@ func (s *Server) prepareServerCredential(ctx context.Context, item deployment.Se
}
}
if item.PrivateKeyRef == "" && s.sshKeys != nil && s.deploymentFactory != nil {
metadata, _, err := s.sshKeys.CreateGenerated(ctx, "服务器 "+item.Name+" SSH密钥")
metadata, _, err := s.sshKeys.CreateGeneratedForProject(ctx, "服务器 "+item.Name+" SSH密钥", item.ProjectID)
if err != nil {
return deployment.Server{}, cleanup, fmt.Errorf("生成 SSH 密钥失败:%w", err)
}

View File

@ -3,8 +3,11 @@ package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
const maxSSHKeyRequestBytes = 2 * 1024 * 1024
@ -13,6 +16,7 @@ type sshKeyRequest struct {
Name string `json:"name" binding:"required"`
PrivateKey string `json:"private_key" binding:"required"`
Passphrase string `json:"passphrase"`
ProjectID string `json:"project_id"`
}
func (s *Server) listSSHKeys(c *gin.Context) {
@ -20,7 +24,14 @@ func (s *Server) listSSHKeys(c *gin.Context) {
s.writeError(c, http.StatusNotImplemented, "SSH 私钥管理接口尚未配置", errors.New("SSH 私钥管理服务未配置"))
return
}
items, err := s.sshKeys.List(c.Request.Context())
projectID := strings.TrimSpace(c.Query("project_id"))
if projectID == "" {
projectID = domain.DefaultProjectID
}
if !s.authorizeServerMutation(c, projectID) {
return
}
items, err := s.sshKeys.ListForProject(c.Request.Context(), projectID)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询 SSH 私钥失败", err)
return
@ -43,7 +54,14 @@ func (s *Server) createSSHKey(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
item, err := s.sshKeys.Create(c.Request.Context(), request.Name, request.PrivateKey, request.Passphrase)
projectID := strings.TrimSpace(request.ProjectID)
if projectID == "" {
projectID = domain.DefaultProjectID
}
if !s.authorizeServerMutation(c, projectID) {
return
}
item, err := s.sshKeys.CreateForProject(c.Request.Context(), request.Name, request.PrivateKey, request.Passphrase, projectID)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥无效", err)
return
@ -61,6 +79,14 @@ func (s *Server) deleteSSHKey(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "SSH 私钥 ID 无效", err)
return
}
metadata, err := s.sshKeys.ValidateReference(c.Request.Context(), reference)
if err != nil {
s.writeError(c, http.StatusBadRequest, "SSH 私钥引用无效", err)
return
}
if !s.authorizeServerMutation(c, metadata.ProjectID) {
return
}
used, err := s.servers.IsPrivateKeyRefUsed(c.Request.Context(), reference)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "检查 SSH 私钥占用状态失败", err)

View File

@ -39,6 +39,7 @@ type Metadata struct {
Algorithm string `json:"algorithm"`
Fingerprint string `json:"fingerprint"`
UpdatedAt time.Time `json:"updated_at"`
ProjectID string `json:"project_id,omitempty"`
}
type Service struct {
@ -50,9 +51,14 @@ func NewService(store Store) *Service {
}
func (s *Service) Create(ctx context.Context, name, privateKey, passphrase string) (Metadata, error) {
return s.CreateForProject(ctx, name, privateKey, passphrase, domain.DefaultProjectID)
}
func (s *Service) CreateForProject(ctx context.Context, name, privateKey, passphrase, projectID string) (Metadata, error) {
if s == nil || s.store == nil {
return Metadata{}, errors.New("SSH 私钥存储未初始化")
}
projectID = normalizeProjectID(projectID)
name = strings.TrimSpace(name)
if name == "" {
return Metadata{}, errors.New("SSH 私钥名称不能为空")
@ -83,17 +89,22 @@ func (s *Service) Create(ctx context.Context, name, privateKey, passphrase strin
"passphrase": passphrase,
"algorithm": signer.PublicKey().Type(),
"fingerprint": ssh.FingerprintSHA256(signer.PublicKey()),
"project_id": projectID,
}
if err := s.store.Put(ctx, reference, payload); err != nil {
return Metadata{}, fmt.Errorf("保存 SSH 私钥失败:%w", err)
}
return Metadata{
ID: id, Name: name, Reference: reference, Algorithm: payload["algorithm"],
Fingerprint: payload["fingerprint"], UpdatedAt: time.Now().UTC(),
Fingerprint: payload["fingerprint"], UpdatedAt: time.Now().UTC(), ProjectID: projectID,
}, nil
}
func (s *Service) CreateGenerated(ctx context.Context, name string) (Metadata, string, error) {
return s.CreateGeneratedForProject(ctx, name, domain.DefaultProjectID)
}
func (s *Service) CreateGeneratedForProject(ctx context.Context, name, projectID string) (Metadata, string, error) {
if s == nil || s.store == nil {
return Metadata{}, "", errors.New("SSH 私钥存储未初始化")
}
@ -109,7 +120,7 @@ func (s *Service) CreateGenerated(ctx context.Context, name string) (Metadata, s
if err != nil {
return Metadata{}, "", fmt.Errorf("编码 SSH 私钥失败:%w", err)
}
metadata, err := s.Create(ctx, name, string(pem.EncodeToMemory(privatePEM)), "")
metadata, err := s.CreateForProject(ctx, name, string(pem.EncodeToMemory(privatePEM)), "", projectID)
if err != nil {
return Metadata{}, "", err
}
@ -164,6 +175,20 @@ func (s *Service) List(ctx context.Context) ([]Metadata, error) {
return result, nil
}
func (s *Service) ListForProject(ctx context.Context, projectID string) ([]Metadata, error) {
items, err := s.List(ctx)
if err != nil {
return nil, err
}
projectID = normalizeProjectID(projectID)
visible := make([]Metadata, 0, len(items))
for _, item := range items {
if normalizeProjectID(item.ProjectID) == projectID {
visible = append(visible, item)
}
}
return visible, nil
}
func (s *Service) ValidateReference(ctx context.Context, reference string) (Metadata, error) {
if s == nil || s.store == nil {
return Metadata{}, errors.New("SSH 私钥存储未初始化")
@ -171,6 +196,17 @@ func (s *Service) ValidateReference(ctx context.Context, reference string) (Meta
return s.metadata(ctx, strings.TrimSpace(reference), time.Time{}, true)
}
func (s *Service) ValidateReferenceForProject(ctx context.Context, reference, projectID string) (Metadata, error) {
metadata, err := s.ValidateReference(ctx, reference)
if err != nil {
return Metadata{}, err
}
if normalizeProjectID(metadata.ProjectID) != normalizeProjectID(projectID) {
return Metadata{}, errors.New("SSH 私钥不属于当前项目")
}
return metadata, nil
}
func (s *Service) Delete(ctx context.Context, id string) error {
if s == nil || s.store == nil {
return errors.New("SSH 私钥存储未初始化")
@ -220,9 +256,18 @@ func (s *Service) metadata(ctx context.Context, reference string, updatedAt time
return Metadata{
ID: id, Name: payload["name"], Reference: reference,
Algorithm: algorithm, Fingerprint: fingerprint, UpdatedAt: updatedAt,
ProjectID: normalizeProjectID(payload["project_id"]),
}, nil
}
func normalizeProjectID(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return domain.DefaultProjectID
}
return value
}
func parsePrivateKey(privateKey, passphrase string) (ssh.Signer, error) {
var (
signer ssh.Signer

View File

@ -141,3 +141,24 @@ func testEncryptedPrivateKey(t *testing.T, passphrase string) string {
}
return string(pem.EncodeToMemory(block))
}
func TestServiceScopesSSHKeyToProject(t *testing.T) {
store, err := secretstore.Open(t.TempDir())
if err != nil {
t.Fatalf("打开敏感信息存储失败:%v", err)
}
service := NewService(store)
created, err := service.CreateForProject(t.Context(), "项目部署私钥", testPrivateKey(t), "", "project-a")
if err != nil {
t.Fatalf("创建项目 SSH 私钥失败:%v", err)
}
if created.ProjectID != "project-a" {
t.Fatalf("SSH 私钥项目归属错误:%+v", created)
}
if _, err := service.ValidateReferenceForProject(t.Context(), created.Reference, "project-a"); err != nil {
t.Fatalf("同项目 SSH 私钥校验失败:%v", err)
}
if _, err := service.ValidateReferenceForProject(t.Context(), created.Reference, "project-b"); err == nil {
t.Fatal("跨项目 SSH 私钥引用不应通过校验")
}
}