承接 PR#13(撤销时删 KV secret)的剩余生命周期: - 账户停用/删除全量清理:新增 revokeUserResourceSecrets(userID),撤销该用户全部 带 secret_ref 的资源绑定并 best-effort 软删 KV 凭证材料(DB 标 revoked 为权威, KV 故障只记日志不阻塞)。接入 4 个账户路径:ManageUser disable/delete、 DeleteUser(管理员硬删)、DeleteSelf(自助删)。 - 30天 purge:新增 listDeletedSecrets(GET /deletedsecrets 分页)+ secretExpired 纯函数 + purgeExpiredVaultSecrets;StartSecretPurgeTask 每日(可配)purge 软删 ≥30天(可配)的 secret,master 节点执行,KV 未配置则 no-op,purge-protection 下安全 no-op(403 容错)。env:HEICODE_SECRET_PURGE_ENABLED/RETENTION_DAYS/ INTERVAL_HOURS/NAME_PREFIX。 - 轮换显式化:UpsertResourceSecret 已有 secret 时改用 rotateSecret(同名新版本) 并审计日志,而非每次 putSecret。 测试:parseDeletedSecretsPage / secretExpired / lastPathSegment 纯函数 + revokeUserResourceSecrets 在 KV 未配置下仍正确标记 revoked、不误伤他人/无密钥绑定。 Refs #2 (secret_store delete/rotate/purge 部分;Manager↔Swarm 契约属 Swarm 侧) Fixes #4 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
975 lines
30 KiB
Go
975 lines
30 KiB
Go
package controller
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var allowedResourceTypes = map[string]bool{
|
|
"git": true, // github / gitea — distinguished by provider
|
|
"vm": true, // 虚拟机 (ssh)
|
|
"database": true, // 数据库
|
|
"blob": true, // 对象存储
|
|
"sk": true,
|
|
"project_document": true,
|
|
"cloud_account": true,
|
|
"cloud_resource": true,
|
|
}
|
|
|
|
var allowedResourceStatuses = map[string]bool{
|
|
"active": true,
|
|
"disabled": true,
|
|
"revoked": true,
|
|
}
|
|
|
|
var secretLikeKeys = map[string]bool{
|
|
"access_key": true,
|
|
"access_key_id": true,
|
|
"api_key": true,
|
|
"client_secret": true,
|
|
"database_password": true,
|
|
"db_password": true,
|
|
"newapi_key": true,
|
|
"password": true,
|
|
"private_key": true,
|
|
"refresh_token": true,
|
|
"secret": true,
|
|
"secret_key": true,
|
|
"ssh_key": true,
|
|
"token": true,
|
|
}
|
|
|
|
const azureKeyVaultSecretNameMaxLen = 127
|
|
const azureKeyVaultSecretNameHashLen = 16
|
|
|
|
type resourcePayload struct {
|
|
TenantId string `json:"tenant_id"`
|
|
ProjectId string `json:"project_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
Name string `json:"name"`
|
|
ResourceType string `json:"resource_type"`
|
|
Provider string `json:"provider"`
|
|
ExternalId string `json:"external_id"`
|
|
SecretRef string `json:"secret_ref"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
PermissionScope map[string]any `json:"permission_scope"`
|
|
Constraints map[string]any `json:"constraints"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type resourceSecretPayload struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
|
|
type resourceResponse struct {
|
|
Id int `json:"id"`
|
|
UserId int `json:"user_id"`
|
|
TenantId string `json:"tenant_id"`
|
|
ProjectId string `json:"project_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
Name string `json:"name"`
|
|
ResourceType string `json:"resource_type"`
|
|
Provider string `json:"provider"`
|
|
ExternalId string `json:"external_id"`
|
|
SecretRef string `json:"secret_ref"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
PermissionScope map[string]any `json:"permission_scope"`
|
|
Constraints map[string]any `json:"constraints"`
|
|
Status string `json:"status"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
type resourceGrantPayload struct {
|
|
TenantId string `json:"tenant_id"`
|
|
ProjectId string `json:"project_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
ResourceId int `json:"resource_id"`
|
|
Role string `json:"role"`
|
|
AgentId string `json:"agent_id"`
|
|
PermissionScope map[string]any `json:"permission_scope"`
|
|
Constraints map[string]any `json:"constraints"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type resourceGrantResponse struct {
|
|
Id int `json:"id"`
|
|
UserId int `json:"user_id"`
|
|
TenantId string `json:"tenant_id"`
|
|
ProjectId string `json:"project_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
ResourceId int `json:"resource_id"`
|
|
Role string `json:"role"`
|
|
AgentId string `json:"agent_id"`
|
|
PermissionScope map[string]any `json:"permission_scope"`
|
|
Constraints map[string]any `json:"constraints"`
|
|
Status string `json:"status"`
|
|
RevokedAt int64 `json:"revoked_at"`
|
|
Resource *resourceResponse `json:"resource,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
type resourceGrantManifestGrant struct {
|
|
GrantId int `json:"grant_id"`
|
|
ResourceId int `json:"resource_id"`
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceRef string `json:"resource_ref"`
|
|
AllowedActions []any `json:"allowed_actions"`
|
|
PermissionScope map[string]any `json:"permission_scope"`
|
|
Constraints map[string]any `json:"constraints"`
|
|
SecretRef string `json:"secret_ref,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type resourceGrantManifestResponse struct {
|
|
UserId int `json:"user_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
AgentRole string `json:"agent_role"`
|
|
TargetAgentRef string `json:"target_agent_ref"`
|
|
ResourceGrants []resourceGrantManifestGrant `json:"resource_grants"`
|
|
}
|
|
|
|
func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
|
|
p.TenantId = strings.TrimSpace(p.TenantId)
|
|
p.ProjectId = strings.TrimSpace(p.ProjectId)
|
|
p.BindingScope = strings.TrimSpace(p.BindingScope)
|
|
p.Name = strings.TrimSpace(p.Name)
|
|
p.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType))
|
|
p.Provider = strings.TrimSpace(p.Provider)
|
|
p.ExternalId = strings.TrimSpace(p.ExternalId)
|
|
p.SecretRef = strings.TrimSpace(p.SecretRef)
|
|
p.Status = strings.ToLower(strings.TrimSpace(p.Status))
|
|
|
|
if p.BindingScope == "" {
|
|
p.BindingScope = inferResourceBindingScope(p)
|
|
}
|
|
if p.Name == "" {
|
|
return p, errors.New("name required")
|
|
}
|
|
if !allowedResourceTypes[p.ResourceType] {
|
|
return p, fmt.Errorf("resource_type must be one of %s", strings.Join(sortedResourceTypes(), ", "))
|
|
}
|
|
if p.Provider == "" {
|
|
p.Provider = "custom"
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = "active"
|
|
}
|
|
if !allowedResourceStatuses[p.Status] {
|
|
return p, errors.New("status must be active, disabled, or revoked")
|
|
}
|
|
if p.SecretRef != "" && !strings.HasPrefix(p.SecretRef, "azkv://") {
|
|
return p, errors.New("secret_ref must use azkv://<vault>/secrets/<name> Azure Key Vault reference")
|
|
}
|
|
if p.Metadata == nil {
|
|
p.Metadata = map[string]any{}
|
|
}
|
|
if p.PermissionScope == nil {
|
|
p.PermissionScope = map[string]any{}
|
|
}
|
|
if p.Constraints == nil {
|
|
p.Constraints = map[string]any{}
|
|
}
|
|
if containsPlaintextSecret(p.Metadata) || containsPlaintextSecret(p.PermissionScope) || containsPlaintextSecret(p.Constraints) {
|
|
return p, errors.New("plaintext secrets are not allowed; store credentials in Secret Store and provide secret_ref only")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) {
|
|
p.TenantId = strings.TrimSpace(p.TenantId)
|
|
p.ProjectId = strings.TrimSpace(p.ProjectId)
|
|
p.BindingScope = strings.TrimSpace(p.BindingScope)
|
|
p.Role = strings.TrimSpace(p.Role)
|
|
p.AgentId = strings.TrimSpace(p.AgentId)
|
|
p.Status = strings.ToLower(strings.TrimSpace(p.Status))
|
|
|
|
if p.ResourceId <= 0 {
|
|
return p, errors.New("resource_id required")
|
|
}
|
|
if p.Role == "" {
|
|
return p, errors.New("role required")
|
|
}
|
|
if p.AgentId == "" {
|
|
return p, errors.New("agent_id required")
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = "active"
|
|
}
|
|
if !allowedResourceStatuses[p.Status] {
|
|
return p, errors.New("status must be active, disabled, or revoked")
|
|
}
|
|
if p.PermissionScope == nil {
|
|
p.PermissionScope = map[string]any{}
|
|
}
|
|
if p.Constraints == nil {
|
|
p.Constraints = map[string]any{}
|
|
}
|
|
if containsPlaintextSecret(p.PermissionScope) || containsPlaintextSecret(p.Constraints) {
|
|
return p, errors.New("plaintext secrets are not allowed; store credentials in Secret Store and provide secret_ref only")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func inferResourceBindingScope(p resourcePayload) string {
|
|
switch {
|
|
case p.ExternalId != "":
|
|
return p.ExternalId
|
|
case p.ProjectId != "":
|
|
return p.ProjectId
|
|
case p.TenantId != "":
|
|
return p.TenantId
|
|
case p.Name != "":
|
|
return p.Name
|
|
default:
|
|
return "resource"
|
|
}
|
|
}
|
|
|
|
func sortedResourceTypes() []string {
|
|
types := make([]string, 0, len(allowedResourceTypes))
|
|
for resourceType := range allowedResourceTypes {
|
|
types = append(types, resourceType)
|
|
}
|
|
sort.Strings(types)
|
|
return types
|
|
}
|
|
|
|
func containsPlaintextSecret(value any) bool {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range v {
|
|
if isSecretLikeKey(key) {
|
|
return true
|
|
}
|
|
if containsPlaintextSecret(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range v {
|
|
if containsPlaintextSecret(child) {
|
|
return true
|
|
}
|
|
}
|
|
case string:
|
|
return valueLooksLikeSecret(v)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isSecretLikeKey(key string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
|
normalized = strings.ReplaceAll(normalized, "-", "_")
|
|
normalized = strings.ReplaceAll(normalized, " ", "_")
|
|
if secretLikeKeys[normalized] {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(normalized, "_token") || strings.HasSuffix(normalized, "_secret") || strings.HasSuffix(normalized, "_password") || strings.HasSuffix(normalized, "_private_key")
|
|
}
|
|
|
|
// valueLooksLikeSecret scans a string VALUE (not just a key name) for high-
|
|
// confidence plaintext credential patterns, so a secret hidden under an
|
|
// innocuous key (e.g. {"note":"sk-live-..."}) is still rejected. Patterns are
|
|
// kept deliberately tight to avoid false positives on ordinary content. An
|
|
// azkv:// secret_ref never matches any of these, so references stay allowed.
|
|
func valueLooksLikeSecret(value string) bool {
|
|
s := strings.TrimSpace(value)
|
|
if len(s) < 12 {
|
|
return false
|
|
}
|
|
if strings.Contains(s, "PRIVATE KEY-----") {
|
|
return true
|
|
}
|
|
prefixes := []struct {
|
|
p string
|
|
min int
|
|
}{
|
|
{"sk-", 20}, {"sk_live_", 20}, {"sk_test_", 20},
|
|
{"ghp_", 24}, {"gho_", 24}, {"github_pat_", 24},
|
|
{"xoxb-", 24}, {"xoxp-", 24}, {"xoxa-", 24},
|
|
{"AKIA", 16}, {"ASIA", 16}, {"AIza", 24},
|
|
}
|
|
for _, pf := range prefixes {
|
|
if len(s) >= pf.min && strings.HasPrefix(s, pf.p) {
|
|
return true
|
|
}
|
|
}
|
|
return looksLikeJWT(s)
|
|
}
|
|
|
|
func looksLikeJWT(s string) bool {
|
|
if !strings.HasPrefix(s, "eyJ") {
|
|
return false
|
|
}
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) != 3 {
|
|
return false
|
|
}
|
|
for _, p := range parts {
|
|
if len(p) < 8 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func marshalResourceJSON(value map[string]any) (string, error) {
|
|
if value == nil {
|
|
value = map[string]any{}
|
|
}
|
|
data, err := common.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func unmarshalResourceJSON(raw string) map[string]any {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return map[string]any{}
|
|
}
|
|
var value map[string]any
|
|
if err := common.UnmarshalJsonStr(raw, &value); err != nil || value == nil {
|
|
return map[string]any{}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func resourceToResponse(resource model.ResourceBinding) resourceResponse {
|
|
return resourceResponse{
|
|
Id: resource.Id,
|
|
UserId: resource.UserId,
|
|
TenantId: resource.TenantId,
|
|
ProjectId: resource.ProjectId,
|
|
BindingScope: resource.BindingScope,
|
|
Name: resource.Name,
|
|
ResourceType: resource.ResourceType,
|
|
Provider: resource.Provider,
|
|
ExternalId: resource.ExternalId,
|
|
SecretRef: resource.SecretRef,
|
|
Metadata: unmarshalResourceJSON(resource.Metadata),
|
|
PermissionScope: unmarshalResourceJSON(resource.PermissionScope),
|
|
Constraints: unmarshalResourceJSON(resource.Constraints),
|
|
Status: resource.Status,
|
|
CreatedAt: resource.CreatedAt,
|
|
UpdatedAt: resource.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func resourceGrantToResponse(grant model.ResourceGrant, resource *model.ResourceBinding) resourceGrantResponse {
|
|
resp := resourceGrantResponse{
|
|
Id: grant.Id,
|
|
UserId: grant.UserId,
|
|
TenantId: grant.TenantId,
|
|
ProjectId: grant.ProjectId,
|
|
BindingScope: grant.BindingScope,
|
|
ResourceId: grant.ResourceId,
|
|
Role: grant.Role,
|
|
AgentId: grant.AgentId,
|
|
PermissionScope: unmarshalResourceJSON(grant.PermissionScope),
|
|
Constraints: unmarshalResourceJSON(grant.Constraints),
|
|
Status: grant.Status,
|
|
RevokedAt: grant.RevokedAt,
|
|
CreatedAt: grant.CreatedAt,
|
|
UpdatedAt: grant.UpdatedAt,
|
|
}
|
|
if resource != nil {
|
|
resourceResp := resourceToResponse(*resource)
|
|
resp.Resource = &resourceResp
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func resourceGrantToManifestGrant(grant model.ResourceGrant, resource model.ResourceBinding) resourceGrantManifestGrant {
|
|
permissionScope := unmarshalResourceJSON(grant.PermissionScope)
|
|
return resourceGrantManifestGrant{
|
|
GrantId: grant.Id,
|
|
ResourceId: grant.ResourceId,
|
|
ResourceType: resource.ResourceType,
|
|
ResourceRef: resourceManifestRef(resource),
|
|
AllowedActions: manifestAllowedActions(permissionScope),
|
|
PermissionScope: permissionScope,
|
|
Constraints: unmarshalResourceJSON(grant.Constraints),
|
|
SecretRef: resource.SecretRef,
|
|
Status: grant.Status,
|
|
}
|
|
}
|
|
|
|
func resourceManifestRef(resource model.ResourceBinding) string {
|
|
for _, value := range []string{resource.ExternalId, resource.BindingScope, resource.Name} {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return fmt.Sprintf("resource:%d", resource.Id)
|
|
}
|
|
|
|
func manifestAllowedActions(permissionScope map[string]any) []any {
|
|
actions, ok := permissionScope["actions"]
|
|
if !ok {
|
|
return []any{}
|
|
}
|
|
switch v := actions.(type) {
|
|
case []any:
|
|
return v
|
|
case []string:
|
|
out := make([]any, 0, len(v))
|
|
for _, action := range v {
|
|
out = append(out, action)
|
|
}
|
|
return out
|
|
case string:
|
|
if strings.TrimSpace(v) == "" {
|
|
return []any{}
|
|
}
|
|
return []any{v}
|
|
default:
|
|
return []any{}
|
|
}
|
|
}
|
|
|
|
func ListResources(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
query := model.DB.Where("user_id = ?", userId)
|
|
if tenantId := strings.TrimSpace(c.Query("tenant_id")); tenantId != "" {
|
|
query = query.Where("tenant_id = ?", tenantId)
|
|
}
|
|
if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" {
|
|
query = query.Where("project_id = ?", projectId)
|
|
}
|
|
if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" {
|
|
query = query.Where("binding_scope = ?", bindingScope)
|
|
}
|
|
resourceType := strings.TrimSpace(c.Query("resource_type"))
|
|
if resourceType == "" {
|
|
resourceType = strings.TrimSpace(c.Query("type"))
|
|
}
|
|
if resourceType != "" {
|
|
query = query.Where("resource_type = ?", strings.ToLower(resourceType))
|
|
}
|
|
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
|
query = query.Where("status = ?", strings.ToLower(status))
|
|
}
|
|
var resources []model.ResourceBinding
|
|
if err := query.Order("id desc").Find(&resources).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
items := make([]resourceResponse, 0, len(resources))
|
|
for _, resource := range resources {
|
|
items = append(items, resourceToResponse(resource))
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items})
|
|
}
|
|
|
|
func CreateResource(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var payload resourcePayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
|
return
|
|
}
|
|
payload, err := normalizeResourcePayload(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
metadata, permissionScope, constraints, err := marshalResourcePayloadJSON(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
resource := model.ResourceBinding{
|
|
UserId: userId,
|
|
TenantId: payload.TenantId,
|
|
ProjectId: payload.ProjectId,
|
|
BindingScope: payload.BindingScope,
|
|
Name: payload.Name,
|
|
ResourceType: payload.ResourceType,
|
|
Provider: payload.Provider,
|
|
ExternalId: payload.ExternalId,
|
|
SecretRef: payload.SecretRef,
|
|
Metadata: metadata,
|
|
PermissionScope: permissionScope,
|
|
Constraints: constraints,
|
|
Status: payload.Status,
|
|
}
|
|
if err := model.DB.Create(&resource).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, resourceToResponse(resource))
|
|
}
|
|
|
|
func UpdateResource(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var resource model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "resource not found")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var payload resourcePayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
|
return
|
|
}
|
|
payload, err := normalizeResourcePayload(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
metadata, permissionScope, constraints, err := marshalResourcePayloadJSON(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
resource.TenantId = payload.TenantId
|
|
resource.ProjectId = payload.ProjectId
|
|
resource.BindingScope = payload.BindingScope
|
|
resource.Name = payload.Name
|
|
resource.ResourceType = payload.ResourceType
|
|
resource.Provider = payload.Provider
|
|
resource.ExternalId = payload.ExternalId
|
|
resource.SecretRef = payload.SecretRef
|
|
resource.Metadata = metadata
|
|
resource.PermissionScope = permissionScope
|
|
resource.Constraints = constraints
|
|
resource.Status = payload.Status
|
|
if err := model.DB.Save(&resource).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, resourceToResponse(resource))
|
|
}
|
|
|
|
func DeleteResource(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var resource model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "resource not found")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
now := common.GetTimestamp()
|
|
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
|
resource.Status = "revoked"
|
|
if err := tx.Save(&resource).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.ResourceGrant{}).
|
|
Where("user_id = ? AND resource_id = ? AND status <> ?", userId, resource.Id, "revoked").
|
|
Updates(map[string]any{
|
|
"status": "revoked",
|
|
"revoked_at": now,
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// Revoking a resource must also remove the real credential material from the
|
|
// Secret Store, not just flip DB status to revoked (issue #4). Best-effort:
|
|
// the DB revoke is already committed, so an external Key Vault failure must
|
|
// not block the response — but it is logged so a lingering secret is visible
|
|
// and can be purged manually. (secret_ref is a pointer, not the secret.)
|
|
if strings.TrimSpace(resource.SecretRef) != "" {
|
|
if store, sErr := newSecretStoreClientFromEnv(); sErr != nil {
|
|
common.SysLog(fmt.Sprintf("DeleteResource: secret store unavailable, KV material NOT deleted for binding %d: %s", resource.Id, sErr.Error()))
|
|
} else if dErr := store.deleteSecret(resource.SecretRef); dErr != nil {
|
|
common.SysLog(fmt.Sprintf("DeleteResource: KV secret delete failed for binding %d: %s", resource.Id, dErr.Error()))
|
|
}
|
|
}
|
|
common.ApiSuccess(c, resourceToResponse(resource))
|
|
}
|
|
|
|
func UpsertResourceSecret(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var resource model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "resource not found")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var payload resourceSecretPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
|
return
|
|
}
|
|
if len(payload.Data) == 0 {
|
|
common.ApiErrorMsg(c, "secret data required")
|
|
return
|
|
}
|
|
client, err := newSecretStoreClientFromEnv()
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
// Issue #4 rotation: if this binding already has a secret, rotate it (Azure
|
|
// stores a new version under the same ref/name); otherwise create it. Either
|
|
// way the plaintext credential only lives in Key Vault, never in our DB.
|
|
var secretRef string
|
|
rotated := strings.TrimSpace(resource.SecretRef) != ""
|
|
if rotated {
|
|
secretRef, err = client.rotateSecret(resource.SecretRef, payload.Data)
|
|
} else {
|
|
secretRef, err = client.putSecret(resourceSecretName(resource), payload.Data)
|
|
}
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if rotated {
|
|
common.SysLog(fmt.Sprintf("UpsertResourceSecret: rotated KV secret for binding %d (user %d) — new version", resource.Id, userId))
|
|
}
|
|
resource.SecretRef = secretRef
|
|
if err := model.DB.Save(&resource).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, gin.H{
|
|
"resource": resourceToResponse(resource),
|
|
"secret_ref": resource.SecretRef,
|
|
})
|
|
}
|
|
|
|
func resourceSecretName(resource model.ResourceBinding) string {
|
|
scope := resource.BindingScope
|
|
if strings.TrimSpace(scope) == "" {
|
|
scope = resource.ExternalId
|
|
}
|
|
if strings.TrimSpace(scope) == "" {
|
|
scope = resource.Name
|
|
}
|
|
return compactAzureSecretName(
|
|
fmt.Sprintf("users-%d-bindings-", resource.UserId),
|
|
sanitizeSecretPathSegment(scope),
|
|
fmt.Sprintf("-resources-%d", resource.Id),
|
|
)
|
|
}
|
|
|
|
func sanitizeSecretPathSegment(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "resource"
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
b.WriteRune(r)
|
|
case r >= 'A' && r <= 'Z':
|
|
b.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '-':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func compactAzureSecretName(prefix string, segment string, suffix string) string {
|
|
name := prefix + segment + suffix
|
|
if len(name) <= azureKeyVaultSecretNameMaxLen {
|
|
return name
|
|
}
|
|
hash := sha256.Sum256([]byte(segment))
|
|
hashSuffix := hex.EncodeToString(hash[:])[:azureKeyVaultSecretNameHashLen]
|
|
maxSegmentLen := azureKeyVaultSecretNameMaxLen - len(prefix) - len(suffix) - 1 - azureKeyVaultSecretNameHashLen
|
|
if maxSegmentLen < 1 {
|
|
maxSegmentLen = 1
|
|
}
|
|
if len(segment) > maxSegmentLen {
|
|
segment = segment[:maxSegmentLen]
|
|
}
|
|
segment = strings.Trim(segment, "-")
|
|
if segment == "" {
|
|
segment = "resource"
|
|
if len(segment) > maxSegmentLen {
|
|
segment = segment[:maxSegmentLen]
|
|
}
|
|
}
|
|
return prefix + segment + "-" + hashSuffix + suffix
|
|
}
|
|
|
|
func marshalResourcePayloadJSON(payload resourcePayload) (string, string, string, error) {
|
|
metadata, err := marshalResourceJSON(payload.Metadata)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
permissionScope, err := marshalResourceJSON(payload.PermissionScope)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
constraints, err := marshalResourceJSON(payload.Constraints)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return metadata, permissionScope, constraints, nil
|
|
}
|
|
|
|
func ListResourceGrants(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
query := model.DB.Where("user_id = ?", userId)
|
|
if tenantId := strings.TrimSpace(c.Query("tenant_id")); tenantId != "" {
|
|
query = query.Where("tenant_id = ?", tenantId)
|
|
}
|
|
if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" {
|
|
query = query.Where("project_id = ?", projectId)
|
|
}
|
|
if bindingScope := strings.TrimSpace(c.Query("binding_scope")); bindingScope != "" {
|
|
query = query.Where("binding_scope = ?", bindingScope)
|
|
}
|
|
if agentId := strings.TrimSpace(c.Query("agent_id")); agentId != "" {
|
|
query = query.Where("agent_id = ?", agentId)
|
|
}
|
|
if resourceId := strings.TrimSpace(c.Query("resource_id")); resourceId != "" {
|
|
query = query.Where("resource_id = ?", resourceId)
|
|
}
|
|
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
|
query = query.Where("status = ?", strings.ToLower(status))
|
|
}
|
|
var grants []model.ResourceGrant
|
|
if err := query.Order("id desc").Find(&grants).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
items := make([]resourceGrantResponse, 0, len(grants))
|
|
for _, grant := range grants {
|
|
items = append(items, resourceGrantToResponse(grant, nil))
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items})
|
|
}
|
|
|
|
func GenerateResourceGrantManifest(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
|
agentId := strings.TrimSpace(c.Query("agent_id"))
|
|
role := strings.TrimSpace(c.Query("role"))
|
|
|
|
query := model.DB.Where("user_id = ? AND status = ?", userId, "active")
|
|
if bindingScope != "" {
|
|
query = query.Where("binding_scope = ?", bindingScope)
|
|
}
|
|
if agentId != "" {
|
|
query = query.Where("agent_id = ?", agentId)
|
|
}
|
|
if role != "" {
|
|
query = query.Where("role = ?", role)
|
|
}
|
|
|
|
var grants []model.ResourceGrant
|
|
if err := query.Order("id asc").Find(&grants).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
|
|
items := make([]resourceGrantManifestGrant, 0, len(grants))
|
|
for _, grant := range grants {
|
|
var resource model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", grant.ResourceId, userId, "active").First(&resource).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
continue
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
items = append(items, resourceGrantToManifestGrant(grant, resource))
|
|
}
|
|
|
|
common.ApiSuccess(c, resourceGrantManifestResponse{
|
|
UserId: userId,
|
|
BindingScope: bindingScope,
|
|
AgentRole: role,
|
|
TargetAgentRef: agentId,
|
|
ResourceGrants: items,
|
|
})
|
|
}
|
|
|
|
func CreateResourceGrant(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var payload resourceGrantPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
|
return
|
|
}
|
|
payload, err := normalizeResourceGrantPayload(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
payload = inheritResourceGrantScope(payload, resource)
|
|
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
grant := model.ResourceGrant{
|
|
UserId: userId,
|
|
TenantId: payload.TenantId,
|
|
ProjectId: payload.ProjectId,
|
|
BindingScope: payload.BindingScope,
|
|
ResourceId: payload.ResourceId,
|
|
Role: payload.Role,
|
|
AgentId: payload.AgentId,
|
|
PermissionScope: permissionScope,
|
|
Constraints: constraints,
|
|
Status: payload.Status,
|
|
}
|
|
if err := model.DB.Create(&grant).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, resourceGrantToResponse(grant, &resource))
|
|
}
|
|
|
|
func UpdateResourceGrant(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var grant model.ResourceGrant
|
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&grant).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "resource grant not found")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var payload resourceGrantPayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
|
|
return
|
|
}
|
|
payload, err := normalizeResourceGrantPayload(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
resource, err := findGrantResource(userId, payload.ResourceId, payload.BindingScope)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
payload = inheritResourceGrantScope(payload, resource)
|
|
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
grant.TenantId = payload.TenantId
|
|
grant.ProjectId = payload.ProjectId
|
|
grant.BindingScope = payload.BindingScope
|
|
grant.ResourceId = payload.ResourceId
|
|
grant.Role = payload.Role
|
|
grant.AgentId = payload.AgentId
|
|
grant.PermissionScope = permissionScope
|
|
grant.Constraints = constraints
|
|
grant.Status = payload.Status
|
|
if payload.Status == "revoked" && grant.RevokedAt == 0 {
|
|
grant.RevokedAt = common.GetTimestamp()
|
|
}
|
|
if payload.Status != "revoked" {
|
|
grant.RevokedAt = 0
|
|
}
|
|
if err := model.DB.Save(&grant).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, resourceGrantToResponse(grant, &resource))
|
|
}
|
|
|
|
func DeleteResourceGrant(c *gin.Context) {
|
|
userId := c.GetInt("id")
|
|
var grant model.ResourceGrant
|
|
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&grant).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "resource grant not found")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
grant.Status = "revoked"
|
|
if grant.RevokedAt == 0 {
|
|
grant.RevokedAt = common.GetTimestamp()
|
|
}
|
|
if err := model.DB.Save(&grant).Error; err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var resource model.ResourceBinding
|
|
resourcePtr := (*model.ResourceBinding)(nil)
|
|
if err := model.DB.Where("id = ? AND user_id = ?", grant.ResourceId, userId).First(&resource).Error; err == nil {
|
|
resourcePtr = &resource
|
|
}
|
|
common.ApiSuccess(c, resourceGrantToResponse(grant, resourcePtr))
|
|
}
|
|
|
|
func marshalResourceGrantPayloadJSON(payload resourceGrantPayload) (string, string, error) {
|
|
permissionScope, err := marshalResourceJSON(payload.PermissionScope)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
constraints, err := marshalResourceJSON(payload.Constraints)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return permissionScope, constraints, nil
|
|
}
|
|
|
|
func inheritResourceGrantScope(payload resourceGrantPayload, resource model.ResourceBinding) resourceGrantPayload {
|
|
if payload.BindingScope == "" {
|
|
payload.BindingScope = resource.BindingScope
|
|
}
|
|
if payload.TenantId == "" {
|
|
payload.TenantId = resource.TenantId
|
|
}
|
|
if payload.ProjectId == "" {
|
|
payload.ProjectId = resource.ProjectId
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func findGrantResource(userId int, resourceId int, bindingScope string) (model.ResourceBinding, error) {
|
|
var resource model.ResourceBinding
|
|
if err := model.DB.Where("id = ? AND user_id = ?", resourceId, userId).First(&resource).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return resource, errors.New("resource not found")
|
|
}
|
|
return resource, err
|
|
}
|
|
if bindingScope != "" && resource.BindingScope != "" && resource.BindingScope != bindingScope {
|
|
return resource, errors.New("resource binding_scope does not match grant binding_scope")
|
|
}
|
|
return resource, nil
|
|
}
|