Files
heicode-win/heicode/controller/resource.go
T
gongzhiyong ba02ae5be7 feat: align manager agnet boundaries
- add Manager user_context, NewAPI billing_context, and Agnet agent_runtime deployment fields

- move resource binding/grant scope toward user-owned binding_scope and secret_ref-only paths

- document OpenBao internal access and unified heicode.xinghanlab.com routing boundaries

- fix Manager session user id preservation after external auth login
2026-05-04 09:28:03 +08:00

715 lines
21 KiB
Go

package controller
import (
"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,
"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,
}
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"`
AgnetId string `json:"agnet_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"`
AgnetId string `json:"agnet_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"`
}
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.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.AgnetId = strings.TrimSpace(p.AgnetId)
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.AgnetId == "" {
return p, errors.New("agnet_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
}
}
}
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")
}
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,
AgnetId: grant.AgnetId,
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 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)
}
if resourceType := strings.TrimSpace(c.Query("resource_type")); resourceType != "" {
query = query.Where("resource_type = ?", strings.ToLower(resourceType))
}
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")
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceBinding{})
if res.Error != nil {
common.ApiError(c, res.Error)
return
}
if res.RowsAffected == 0 {
common.ApiErrorMsg(c, "resource not found")
return
}
common.ApiSuccess(c, gin.H{"deleted": true})
}
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
}
secretPath := resourceSecretPath(resource)
if err := client.putKV2(secretPath, payload.Data); err != nil {
common.ApiError(c, err)
return
}
resource.SecretRef = fmt.Sprintf("vault://%s/%s", client.mount, secretPath)
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 resourceSecretPath(resource model.ResourceBinding) string {
scope := resource.BindingScope
if strings.TrimSpace(scope) == "" {
scope = resource.ExternalId
}
if strings.TrimSpace(scope) == "" {
scope = resource.Name
}
return strings.Join([]string{
"users",
fmt.Sprintf("%d", resource.UserId),
"bindings",
sanitizeSecretPathSegment(scope),
"resources",
fmt.Sprintf("%d", resource.Id),
}, "/")
}
func sanitizeSecretPathSegment(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "_"
}
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 == '_' || r == '-' || r == '.':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return b.String()
}
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 agnetId := strings.TrimSpace(c.Query("agnet_id")); agnetId != "" {
query = query.Where("agnet_id = ?", agnetId)
}
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 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,
AgnetId: payload.AgnetId,
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.AgnetId = payload.AgnetId
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")
res := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).Delete(&model.ResourceGrant{})
if res.Error != nil {
common.ApiError(c, res.Error)
return
}
if res.RowsAffected == 0 {
common.ApiErrorMsg(c, "resource grant not found")
return
}
common.ApiSuccess(c, gin.H{"deleted": true})
}
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
}