task: implement manager resource grants
This commit is contained in:
+144
-227
@@ -2,8 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
@@ -13,47 +12,57 @@ 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"`
|
||||
type resourceBindingPayload struct {
|
||||
TenantId string `json:"tenant_id"`
|
||||
ProjectId string `json:"project_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Provider string `json:"provider"`
|
||||
Target string `json:"target"`
|
||||
ResourceRef string `json:"resource_ref"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Scopes []string `json:"scopes"`
|
||||
PermissionScope map[string]any `json:"permission_scope"`
|
||||
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"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -62,47 +71,43 @@ type resourceGrantResponse 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"`
|
||||
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
|
||||
|
||||
+116
-119
@@ -1,143 +1,140 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNormalizeResourcePayloadAcceptsP1ResourceModel(t *testing.T) {
|
||||
payload, err := normalizeResourcePayload(resourcePayload{
|
||||
TenantId: " tenant-a ",
|
||||
ProjectId: " project-a ",
|
||||
Name: " repo ",
|
||||
Type: model.ResourceTypeGit,
|
||||
Target: "https://github.com/example/repo",
|
||||
Metadata: map[string]any{
|
||||
"repo_url": "https://github.com/example/repo",
|
||||
func setupResourceControllerTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db := openTokenControllerTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.ResourceBinding{}, &model.ResourceGrant{}))
|
||||
}
|
||||
|
||||
func decodeResourceBindingData(t *testing.T, raw []byte) resourceBindingResponse {
|
||||
t.Helper()
|
||||
var response tokenAPIResponse
|
||||
require.NoError(t, common.Unmarshal(raw, &response))
|
||||
require.True(t, response.Success, response.Message)
|
||||
var data resourceBindingResponse
|
||||
require.NoError(t, common.Unmarshal(response.Data, &data))
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeResourceGrantData(t *testing.T, raw []byte) resourceGrantResponse {
|
||||
t.Helper()
|
||||
var response tokenAPIResponse
|
||||
require.NoError(t, common.Unmarshal(raw, &response))
|
||||
require.True(t, response.Success, response.Message)
|
||||
var data resourceGrantResponse
|
||||
require.NoError(t, common.Unmarshal(response.Data, &data))
|
||||
return data
|
||||
}
|
||||
|
||||
func TestCreateResourceAcceptsSecretRefOnly(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
|
||||
body := map[string]any{
|
||||
"tenant_id": "tenant-a",
|
||||
"project_id": "project-a",
|
||||
"resource_type": "git",
|
||||
"name": "main repo",
|
||||
"provider": "github",
|
||||
"resource_ref": "https://github.com/example/repo.git",
|
||||
"metadata": map[string]any{
|
||||
"ref": "main",
|
||||
},
|
||||
Scopes: []string{" read ", "write", "read", ""},
|
||||
Constraints: map[string]any{
|
||||
"allowed_paths": []any{".", "docs"},
|
||||
"permission_scope": map[string]any{
|
||||
"actions": []string{"read", "write"},
|
||||
},
|
||||
SecretRef: "vault://tenant-a/git/repo",
|
||||
})
|
||||
"constraints": map[string]any{
|
||||
"paths": []string{"src"},
|
||||
},
|
||||
"secret_ref": "vault://tenant-a/git/main-repo",
|
||||
}
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resources/", body, 7)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "tenant-a", payload.TenantId)
|
||||
require.Equal(t, "project-a", payload.ProjectId)
|
||||
require.Equal(t, "custom", payload.Provider)
|
||||
require.Equal(t, "active", payload.Status)
|
||||
require.Equal(t, []string{"read", "write"}, payload.Scopes)
|
||||
CreateResource(ctx)
|
||||
|
||||
data := decodeResourceBindingData(t, recorder.Body.Bytes())
|
||||
assert.Equal(t, 7, data.UserId)
|
||||
assert.Equal(t, "tenant-a", data.TenantId)
|
||||
assert.Equal(t, "project-a", data.ProjectId)
|
||||
assert.Equal(t, "git", data.ResourceType)
|
||||
assert.Equal(t, "vault://tenant-a/git/main-repo", data.SecretRef)
|
||||
assert.Equal(t, "main", data.Metadata["ref"])
|
||||
assert.NotContains(t, recorder.Body.String(), "plain-token")
|
||||
}
|
||||
|
||||
func TestNormalizeResourcePayloadRejectsRawSecretMaterial(t *testing.T) {
|
||||
_, err := normalizeResourcePayload(resourcePayload{
|
||||
func TestCreateResourceRejectsPlaintextSecret(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
|
||||
body := map[string]any{
|
||||
"tenant_id": "tenant-a",
|
||||
"project_id": "project-a",
|
||||
"resource_type": "cloud_account",
|
||||
"name": "aws account",
|
||||
"resource_ref": "arn:aws:organizations::123456789012:account/o-example/123456789012",
|
||||
"secret": "plain-token",
|
||||
}
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resources/", body, 7)
|
||||
|
||||
CreateResource(ctx)
|
||||
|
||||
var response tokenAPIResponse
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.False(t, response.Success)
|
||||
assert.Contains(t, response.Message, "secret plaintext is not accepted")
|
||||
assert.NotContains(t, recorder.Body.String(), "plain-token")
|
||||
}
|
||||
|
||||
func TestCreateResourceGrantBindsProjectRoleAndSubAgnet(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
|
||||
resource := model.ResourceBinding{
|
||||
UserId: 7,
|
||||
TenantId: "tenant-a",
|
||||
ProjectId: "project-a",
|
||||
Name: "cloud",
|
||||
Type: model.ResourceTypeCloudAccount,
|
||||
Target: "azure-subscription-1",
|
||||
Metadata: map[string]any{
|
||||
"access_token": "must-not-be-saved",
|
||||
ResourceType: "cloud_resource",
|
||||
Name: "staging vm",
|
||||
Provider: "azure",
|
||||
ResourceRef: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm-a",
|
||||
Metadata: "{}",
|
||||
PermissionScope: "{}",
|
||||
Constraints: "{}",
|
||||
Status: "active",
|
||||
SecretRef: "vault://tenant-a/azure/vm-a",
|
||||
}
|
||||
require.NoError(t, model.DB.Create(&resource).Error)
|
||||
|
||||
body := map[string]any{
|
||||
"tenant_id": "tenant-a",
|
||||
"project_id": "project-a",
|
||||
"resource_id": resource.Id,
|
||||
"role": "frontend-developer",
|
||||
"sub_agnet_id": "agnet-worker-1",
|
||||
"permission_scope": map[string]any{
|
||||
"actions": []string{"read", "deploy"},
|
||||
},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "must not contain raw secret material")
|
||||
}
|
||||
|
||||
func TestNormalizeResourceGrantPayloadRequiresAgnetRoleBinding(t *testing.T) {
|
||||
payload, err := normalizeResourceGrantPayload(resourceGrantPayload{
|
||||
TenantId: "tenant-a",
|
||||
ProjectId: "project-a",
|
||||
ResourceId: 42,
|
||||
AgnetId: "builder-1",
|
||||
Role: "developer",
|
||||
Scopes: []string{"read", "read", "write"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"read", "write"}, payload.Scopes)
|
||||
require.Equal(t, "active", payload.Status)
|
||||
}
|
||||
|
||||
func TestResourceHandlersCreateResourceAndGrant(t *testing.T) {
|
||||
originalDB := model.DB
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Resource{}, &model.ResourceGrant{}))
|
||||
model.DB = db
|
||||
t.Cleanup(func() { model.DB = originalDB })
|
||||
|
||||
resourceBody := []byte(`{
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
"name":"repo",
|
||||
"type":"git",
|
||||
"target":"https://github.com/example/repo",
|
||||
"metadata":{"repo_url":"https://github.com/example/repo","ref":"main"},
|
||||
"scopes":["read","write"],
|
||||
"constraints":{"allowed_paths":["."]},
|
||||
"secret_ref":"vault://tenant-a/git/repo"
|
||||
}`)
|
||||
resourceRecorder := callResourceHandler(t, http.MethodPost, "/api/resources/", resourceBody, CreateResource)
|
||||
var resourceEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Id int `json:"id"`
|
||||
SecretRef string `json:"secret_ref"`
|
||||
Scopes []string `json:"scopes"`
|
||||
} `json:"data"`
|
||||
"constraints": map[string]any{
|
||||
"environment": "staging",
|
||||
},
|
||||
"audit_note": "grant for deployment smoke test",
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(resourceRecorder.Body.Bytes(), &resourceEnvelope))
|
||||
require.True(t, resourceEnvelope.Success)
|
||||
require.NotZero(t, resourceEnvelope.Data.Id)
|
||||
require.Equal(t, "vault://tenant-a/git/repo", resourceEnvelope.Data.SecretRef)
|
||||
require.Equal(t, []string{"read", "write"}, resourceEnvelope.Data.Scopes)
|
||||
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resource-grants/", body, 7)
|
||||
|
||||
grantBody := []byte(`{
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
"resource_id":` + strconv.Itoa(resourceEnvelope.Data.Id) + `,
|
||||
"agnet_id":"builder-1",
|
||||
"role":"developer",
|
||||
"scopes":["read"]
|
||||
}`)
|
||||
grantRecorder := callResourceHandler(t, http.MethodPost, "/api/resource-grants/", grantBody, CreateResourceGrant)
|
||||
var grantEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ResourceId int `json:"resource_id"`
|
||||
AgnetId string `json:"agnet_id"`
|
||||
Role string `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(grantRecorder.Body.Bytes(), &grantEnvelope))
|
||||
require.True(t, grantEnvelope.Success)
|
||||
require.Equal(t, resourceEnvelope.Data.Id, grantEnvelope.Data.ResourceId)
|
||||
require.Equal(t, "builder-1", grantEnvelope.Data.AgnetId)
|
||||
require.Equal(t, "developer", grantEnvelope.Data.Role)
|
||||
}
|
||||
CreateResourceGrant(ctx)
|
||||
|
||||
func callResourceHandler(t *testing.T, method string, target string, body []byte, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
ctx.Set("id", 7)
|
||||
handler(ctx)
|
||||
return recorder
|
||||
data := decodeResourceGrantData(t, recorder.Body.Bytes())
|
||||
assert.Equal(t, "tenant-a", data.TenantId)
|
||||
assert.Equal(t, "project-a", data.ProjectId)
|
||||
assert.Equal(t, resource.Id, data.ResourceId)
|
||||
assert.Equal(t, "frontend-developer", data.Role)
|
||||
assert.Equal(t, "agnet-worker-1", data.SubAgnetId)
|
||||
assert.Equal(t, "staging", data.Constraints["environment"])
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ func migrateDB() error {
|
||||
&CustomOAuthProvider{},
|
||||
&UserOAuthBinding{},
|
||||
&GitSource{},
|
||||
&Resource{},
|
||||
&ResourceBinding{},
|
||||
&ResourceGrant{},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -332,7 +332,7 @@ func migrateDBFast() error {
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
{&GitSource{}, "GitSource"},
|
||||
{&Resource{}, "Resource"},
|
||||
{&ResourceBinding{}, "ResourceBinding"},
|
||||
{&ResourceGrant{}, "ResourceGrant"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
|
||||
+10
-17
@@ -1,27 +1,19 @@
|
||||
package model
|
||||
|
||||
const (
|
||||
ResourceTypeGit = "git"
|
||||
ResourceTypeSK = "sk"
|
||||
ResourceTypeProjectDocument = "project_document"
|
||||
ResourceTypeCloudAccount = "cloud_account"
|
||||
ResourceTypeCloudResource = "cloud_resource"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
type ResourceBinding struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index;not null"`
|
||||
TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"`
|
||||
ProjectId string `json:"project_id" gorm:"type:varchar(128);index;not null"`
|
||||
ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(128);not null"`
|
||||
Type string `json:"type" gorm:"type:varchar(32);index;not null"`
|
||||
Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"`
|
||||
Target string `json:"target" gorm:"type:varchar(512);not null"`
|
||||
ResourceRef string `json:"resource_ref" gorm:"type:varchar(512);not null"`
|
||||
Metadata string `json:"metadata" gorm:"type:text"`
|
||||
Scopes string `json:"scopes" gorm:"type:text"`
|
||||
PermissionScope string `json:"permission_scope" gorm:"type:text"`
|
||||
Constraints string `json:"constraints" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"type:varchar(32);default:'active'"`
|
||||
SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"`
|
||||
Status string `json:"status" gorm:"type:varchar(32);default:'active';index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
@@ -32,11 +24,12 @@ type ResourceGrant struct {
|
||||
TenantId string `json:"tenant_id" gorm:"type:varchar(64);index;not null"`
|
||||
ProjectId string `json:"project_id" gorm:"type:varchar(128);index;not null"`
|
||||
ResourceId int `json:"resource_id" gorm:"index;not null"`
|
||||
AgnetId string `json:"agnet_id" gorm:"type:varchar(128);index;not null"`
|
||||
Role string `json:"role" gorm:"type:varchar(128);index;not null"`
|
||||
Scopes string `json:"scopes" gorm:"type:text"`
|
||||
Role string `json:"role" gorm:"type:varchar(128);not null"`
|
||||
SubAgnetId string `json:"sub_agnet_id" gorm:"type:varchar(128);index"`
|
||||
PermissionScope string `json:"permission_scope" gorm:"type:text"`
|
||||
Constraints string `json:"constraints" gorm:"type:text"`
|
||||
Status string `json:"status" gorm:"type:varchar(32);default:'active';index"`
|
||||
Status string `json:"status" gorm:"type:varchar(32);default:'active'"`
|
||||
AuditNote string `json:"audit_note" gorm:"type:text"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
@@ -189,7 +189,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
resourceRoute.GET("/", controller.ListResources)
|
||||
resourceRoute.POST("/", controller.CreateResource)
|
||||
resourceRoute.PUT("/:id", controller.UpdateResource)
|
||||
resourceRoute.DELETE("/:id", controller.DeleteResource)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user