权限整改:完成 SSH 私钥项目归属隔离
为 SSH 私钥记录项目归属,限制服务器引用及私钥列表、新增、删除操作的项目权限,兼容历史默认项目数据并补充专项测试。
This commit is contained in:
parent
32d749a0c1
commit
56c88c3ec2
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 私钥引用不应通过校验")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user