task: implement manager resource grants

This commit is contained in:
gongzhiyong
2026-05-02 23:30:23 +08:00
parent 5e4648e608
commit 05182f0277
5 changed files with 325 additions and 419 deletions
+167 -250
View File
@@ -2,8 +2,7 @@ package controller
import (
"errors"
"fmt"
"strconv"
"net/http"
"strings"
"github.com/heicode/manager/common"
@@ -13,96 +12,102 @@ import (
"gorm.io/gorm"
)
type resourcePayload struct {
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Provider string `json:"provider"`
Target string `json:"target"`
Metadata map[string]any `json:"metadata"`
Scopes []string `json:"scopes"`
Constraints map[string]any `json:"constraints"`
SecretRef string `json:"secret_ref"`
Status string `json:"status"`
var allowedResourceTypes = map[string]bool{
"git": true,
"sk": true,
"project_document": true,
"cloud_account": true,
"cloud_resource": true,
}
type resourceResponse struct {
Id int `json:"id"`
UserId int `json:"user_id"`
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
Name string `json:"name"`
Type string `json:"type"`
Provider string `json:"provider"`
Target string `json:"target"`
Metadata map[string]any `json:"metadata"`
Scopes []string `json:"scopes"`
Constraints map[string]any `json:"constraints"`
SecretRef string `json:"secret_ref"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
type resourceBindingPayload struct {
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Provider string `json:"provider"`
ResourceRef string `json:"resource_ref"`
Metadata map[string]any `json:"metadata"`
PermissionScope map[string]any `json:"permission_scope"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
SecretRef string `json:"secret_ref"`
Secret string `json:"secret"`
}
type resourceGrantPayload struct {
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceId int `json:"resource_id"`
AgnetId string `json:"agnet_id"`
Role string `json:"role"`
Scopes []string `json:"scopes"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceId int `json:"resource_id"`
Role string `json:"role"`
SubAgnetId string `json:"sub_agnet_id"`
PermissionScope map[string]any `json:"permission_scope"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
AuditNote string `json:"audit_note"`
}
type resourceBindingResponse struct {
Id int `json:"id"`
UserId int `json:"user_id"`
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Provider string `json:"provider"`
ResourceRef string `json:"resource_ref"`
Metadata map[string]any `json:"metadata"`
PermissionScope map[string]any `json:"permission_scope"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
SecretRef string `json:"secret_ref,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type resourceGrantResponse struct {
Id int `json:"id"`
UserId int `json:"user_id"`
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceId int `json:"resource_id"`
AgnetId string `json:"agnet_id"`
Role string `json:"role"`
Scopes []string `json:"scopes"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Id int `json:"id"`
UserId int `json:"user_id"`
TenantId string `json:"tenant_id"`
ProjectId string `json:"project_id"`
ResourceId int `json:"resource_id"`
Role string `json:"role"`
SubAgnetId string `json:"sub_agnet_id"`
PermissionScope map[string]any `json:"permission_scope"`
Constraints map[string]any `json:"constraints"`
Status string `json:"status"`
AuditNote string `json:"audit_note"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
var allowedResourceTypes = map[string]bool{
model.ResourceTypeGit: true,
model.ResourceTypeSK: true,
model.ResourceTypeProjectDocument: true,
model.ResourceTypeCloudAccount: true,
model.ResourceTypeCloudResource: true,
}
func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingPayload, error) {
p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId)
p.ResourceType = strings.TrimSpace(p.ResourceType)
p.Name = strings.TrimSpace(p.Name)
p.Type = strings.TrimSpace(p.Type)
p.Provider = strings.TrimSpace(p.Provider)
p.Target = strings.TrimSpace(p.Target)
p.SecretRef = strings.TrimSpace(p.SecretRef)
p.ResourceRef = strings.TrimSpace(p.ResourceRef)
p.Status = strings.TrimSpace(p.Status)
p.SecretRef = strings.TrimSpace(p.SecretRef)
p.Secret = strings.TrimSpace(p.Secret)
if p.TenantId == "" {
return p, errors.New("tenant_id required")
}
if p.ProjectId == "" {
return p, errors.New("project_id required")
}
if !allowedResourceTypes[p.ResourceType] {
return p, errors.New("resource_type must be one of git, sk, project_document, cloud_account, cloud_resource")
}
if p.Name == "" {
return p, errors.New("name required")
}
if !allowedResourceTypes[p.Type] {
return p, errors.New("type must be one of git, sk, project_document, cloud_account, cloud_resource")
if p.ResourceRef == "" {
return p, errors.New("resource_ref required")
}
if p.Target == "" {
return p, errors.New("target required")
if p.Secret != "" {
return p, errors.New("secret plaintext is not accepted; store credentials in Secret Store and pass secret_ref")
}
if p.Provider == "" {
p.Provider = "custom"
@@ -110,22 +115,25 @@ func normalizeResourcePayload(p resourcePayload) (resourcePayload, error) {
if p.Status == "" {
p.Status = "active"
}
if err := rejectSecretLikeFields("metadata", p.Metadata); err != nil {
return p, err
if p.Metadata == nil {
p.Metadata = map[string]any{}
}
if err := rejectSecretLikeFields("constraints", p.Constraints); err != nil {
return p, err
if p.PermissionScope == nil {
p.PermissionScope = map[string]any{}
}
if p.Constraints == nil {
p.Constraints = map[string]any{}
}
p.Scopes = normalizeStringList(p.Scopes)
return p, nil
}
func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload, error) {
p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId)
p.AgnetId = strings.TrimSpace(p.AgnetId)
p.Role = strings.TrimSpace(p.Role)
p.SubAgnetId = strings.TrimSpace(p.SubAgnetId)
p.Status = strings.TrimSpace(p.Status)
p.AuditNote = strings.TrimSpace(p.AuditNote)
if p.TenantId == "" {
return p, errors.New("tenant_id required")
}
@@ -135,137 +143,81 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload
if p.ResourceId <= 0 {
return p, errors.New("resource_id required")
}
if p.AgnetId == "" {
return p, errors.New("agnet_id required")
}
if p.Role == "" {
return p, errors.New("role required")
}
if p.Status == "" {
p.Status = "active"
}
if err := rejectSecretLikeFields("constraints", p.Constraints); err != nil {
return p, err
if p.PermissionScope == nil {
p.PermissionScope = map[string]any{}
}
if p.Constraints == nil {
p.Constraints = map[string]any{}
}
p.Scopes = normalizeStringList(p.Scopes)
return p, nil
}
func normalizeStringList(values []string) []string {
items := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
items = append(items, value)
}
return items
}
func rejectSecretLikeFields(path string, value any) error {
switch typed := value.(type) {
case nil:
return nil
case map[string]any:
for key, nested := range typed {
if isSecretLikeKey(key) {
return fmt.Errorf("%s.%s must not contain raw secret material", path, key)
}
if err := rejectSecretLikeFields(path+"."+key, nested); err != nil {
return err
}
}
case []any:
for i, nested := range typed {
if err := rejectSecretLikeFields(fmt.Sprintf("%s[%d]", path, i), nested); err != nil {
return err
}
}
}
return nil
}
func isSecretLikeKey(key string) bool {
key = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_"))
secretKeys := []string{"token", "secret", "password", "private_key", "access_key", "refresh_token", "client_secret"}
for _, secretKey := range secretKeys {
if strings.Contains(key, secretKey) {
return true
}
}
return false
}
func marshalResourcePayload(p resourcePayload) (metadata, scopes, constraints string, err error) {
metadataBytes, err := common.Marshal(defaultMap(p.Metadata))
func marshalResourceMaps(metadata map[string]any, permissionScope map[string]any, constraints map[string]any) (string, string, string, error) {
metadataBytes, err := common.Marshal(metadata)
if err != nil {
return "", "", "", err
}
scopeBytes, err := common.Marshal(p.Scopes)
permissionBytes, err := common.Marshal(permissionScope)
if err != nil {
return "", "", "", err
}
constraintBytes, err := common.Marshal(defaultMap(p.Constraints))
constraintBytes, err := common.Marshal(constraints)
if err != nil {
return "", "", "", err
}
return string(metadataBytes), string(scopeBytes), string(constraintBytes), nil
return string(metadataBytes), string(permissionBytes), string(constraintBytes), nil
}
func marshalResourceGrantPayload(p resourceGrantPayload) (scopes, constraints string, err error) {
scopeBytes, err := common.Marshal(p.Scopes)
if err != nil {
return "", "", err
func parseMapField(raw string) map[string]any {
out := map[string]any{}
if raw != "" {
_ = common.UnmarshalJsonStr(raw, &out)
}
constraintBytes, err := common.Marshal(defaultMap(p.Constraints))
if err != nil {
return "", "", err
}
return string(scopeBytes), string(constraintBytes), nil
return out
}
func defaultMap(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
func resourceBindingToResponse(resource model.ResourceBinding) resourceBindingResponse {
return resourceBindingResponse{
Id: resource.Id,
UserId: resource.UserId,
TenantId: resource.TenantId,
ProjectId: resource.ProjectId,
ResourceType: resource.ResourceType,
Name: resource.Name,
Provider: resource.Provider,
ResourceRef: resource.ResourceRef,
Metadata: parseMapField(resource.Metadata),
PermissionScope: parseMapField(resource.PermissionScope),
Constraints: parseMapField(resource.Constraints),
Status: resource.Status,
SecretRef: resource.SecretRef,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
}
return value
}
func resourceToResponse(src model.Resource) resourceResponse {
metadata := map[string]any{}
constraints := map[string]any{}
var scopes []string
if src.Metadata != "" {
_ = common.UnmarshalJsonStr(src.Metadata, &metadata)
}
if src.Scopes != "" {
_ = common.UnmarshalJsonStr(src.Scopes, &scopes)
}
if src.Constraints != "" {
_ = common.UnmarshalJsonStr(src.Constraints, &constraints)
}
if scopes == nil {
scopes = []string{}
}
return resourceResponse{Id: src.Id, UserId: src.UserId, TenantId: src.TenantId, ProjectId: src.ProjectId, Name: src.Name, Type: src.Type, Provider: src.Provider, Target: src.Target, Metadata: metadata, Scopes: scopes, Constraints: constraints, SecretRef: src.SecretRef, Status: src.Status, CreatedAt: src.CreatedAt, UpdatedAt: src.UpdatedAt}
}
func resourceGrantToResponse(grant model.ResourceGrant) resourceGrantResponse {
constraints := map[string]any{}
var scopes []string
if grant.Scopes != "" {
_ = common.UnmarshalJsonStr(grant.Scopes, &scopes)
return resourceGrantResponse{
Id: grant.Id,
UserId: grant.UserId,
TenantId: grant.TenantId,
ProjectId: grant.ProjectId,
ResourceId: grant.ResourceId,
Role: grant.Role,
SubAgnetId: grant.SubAgnetId,
PermissionScope: parseMapField(grant.PermissionScope),
Constraints: parseMapField(grant.Constraints),
Status: grant.Status,
AuditNote: grant.AuditNote,
CreatedAt: grant.CreatedAt,
UpdatedAt: grant.UpdatedAt,
}
if grant.Constraints != "" {
_ = common.UnmarshalJsonStr(grant.Constraints, &constraints)
}
if scopes == nil {
scopes = []string{}
}
return resourceGrantResponse{Id: grant.Id, UserId: grant.UserId, TenantId: grant.TenantId, ProjectId: grant.ProjectId, ResourceId: grant.ResourceId, AgnetId: grant.AgnetId, Role: grant.Role, Scopes: scopes, Constraints: constraints, Status: grant.Status, CreatedAt: grant.CreatedAt, UpdatedAt: grant.UpdatedAt}
}
func ListResources(c *gin.Context) {
@@ -277,100 +229,59 @@ func ListResources(c *gin.Context) {
if projectId := strings.TrimSpace(c.Query("project_id")); projectId != "" {
query = query.Where("project_id = ?", projectId)
}
var resources []model.Resource
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 _, src := range resources {
items = append(items, resourceToResponse(src))
items := make([]resourceBindingResponse, 0, len(resources))
for _, resource := range resources {
items = append(items, resourceBindingToResponse(resource))
}
common.ApiSuccess(c, gin.H{"items": items})
}
func CreateResource(c *gin.Context) {
userId := c.GetInt("id")
var payload resourcePayload
var payload resourceBindingPayload
if err := c.ShouldBindJSON(&payload); err != nil {
common.ApiErrorMsg(c, "invalid params")
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
payload, err := normalizeResourcePayload(payload)
payload, err := normalizeResourceBindingPayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
metadata, scopes, constraints, err := marshalResourcePayload(payload)
metadata, permissionScope, constraints, err := marshalResourceMaps(payload.Metadata, payload.PermissionScope, payload.Constraints)
if err != nil {
common.ApiError(c, err)
return
}
resource := model.Resource{UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, Name: payload.Name, Type: payload.Type, Provider: payload.Provider, Target: payload.Target, Metadata: metadata, Scopes: scopes, Constraints: constraints, SecretRef: payload.SecretRef, Status: payload.Status}
resource := model.ResourceBinding{
UserId: userId,
TenantId: payload.TenantId,
ProjectId: payload.ProjectId,
ResourceType: payload.ResourceType,
Name: payload.Name,
Provider: payload.Provider,
ResourceRef: payload.ResourceRef,
Metadata: metadata,
PermissionScope: permissionScope,
Constraints: constraints,
Status: payload.Status,
SecretRef: payload.SecretRef,
}
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")
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
common.ApiErrorMsg(c, "invalid resource id")
return
}
var resource model.Resource
if err := model.DB.Where("id = ? AND user_id = ?", 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 {
common.ApiErrorMsg(c, "invalid params")
return
}
payload, err = normalizeResourcePayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
metadata, scopes, constraints, err := marshalResourcePayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
resource.TenantId = payload.TenantId
resource.ProjectId = payload.ProjectId
resource.Name = payload.Name
resource.Type = payload.Type
resource.Provider = payload.Provider
resource.Target = payload.Target
resource.Metadata = metadata
resource.Scopes = scopes
resource.Constraints = constraints
resource.SecretRef = payload.SecretRef
resource.Status = payload.Status
if err := model.DB.Save(&resource).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, resourceToResponse(resource))
common.ApiSuccess(c, resourceBindingToResponse(resource))
}
func DeleteResource(c *gin.Context) {
userId := c.GetInt("id")
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
common.ApiErrorMsg(c, "invalid resource id")
return
}
res := model.DB.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Resource{})
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
@@ -407,7 +318,7 @@ func CreateResourceGrant(c *gin.Context) {
userId := c.GetInt("id")
var payload resourceGrantPayload
if err := c.ShouldBindJSON(&payload); err != nil {
common.ApiErrorMsg(c, "invalid params")
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
payload, err := normalizeResourceGrantPayload(payload)
@@ -415,7 +326,7 @@ func CreateResourceGrant(c *gin.Context) {
common.ApiError(c, err)
return
}
var resource model.Resource
var resource model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ? AND tenant_id = ? AND project_id = ?", payload.ResourceId, userId, payload.TenantId, payload.ProjectId).First(&resource).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "resource not found")
@@ -424,12 +335,23 @@ func CreateResourceGrant(c *gin.Context) {
common.ApiError(c, err)
return
}
scopes, constraints, err := marshalResourceGrantPayload(payload)
_, permissionScope, constraints, err := marshalResourceMaps(map[string]any{}, payload.PermissionScope, payload.Constraints)
if err != nil {
common.ApiError(c, err)
return
}
grant := model.ResourceGrant{UserId: userId, TenantId: payload.TenantId, ProjectId: payload.ProjectId, ResourceId: payload.ResourceId, AgnetId: payload.AgnetId, Role: payload.Role, Scopes: scopes, Constraints: constraints, Status: payload.Status}
grant := model.ResourceGrant{
UserId: userId,
TenantId: payload.TenantId,
ProjectId: payload.ProjectId,
ResourceId: payload.ResourceId,
Role: payload.Role,
SubAgnetId: payload.SubAgnetId,
PermissionScope: permissionScope,
Constraints: constraints,
Status: payload.Status,
AuditNote: payload.AuditNote,
}
if err := model.DB.Create(&grant).Error; err != nil {
common.ApiError(c, err)
return
@@ -439,12 +361,7 @@ func CreateResourceGrant(c *gin.Context) {
func DeleteResourceGrant(c *gin.Context) {
userId := c.GetInt("id")
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
common.ApiErrorMsg(c, "invalid grant id")
return
}
res := model.DB.Where("id = ? AND user_id = ?", id, userId).Delete(&model.ResourceGrant{})
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