task: implement P1 manager resource model
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
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"`
|
||||
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 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"`
|
||||
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"`
|
||||
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"`
|
||||
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.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.TenantId == "" {
|
||||
return p, errors.New("tenant_id required")
|
||||
}
|
||||
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.Role = strings.TrimSpace(p.Role)
|
||||
p.AgnetId = strings.TrimSpace(p.AgnetId)
|
||||
p.Status = strings.ToLower(strings.TrimSpace(p.Status))
|
||||
|
||||
if p.TenantId == "" {
|
||||
return p, errors.New("tenant_id required")
|
||||
}
|
||||
if p.ProjectId == "" {
|
||||
return p, errors.New("project_id required")
|
||||
}
|
||||
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 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,
|
||||
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,
|
||||
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 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,
|
||||
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.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 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 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.TenantId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
grant := model.ResourceGrant{
|
||||
UserId: userId,
|
||||
TenantId: payload.TenantId,
|
||||
ProjectId: payload.ProjectId,
|
||||
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.TenantId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
grant.TenantId = payload.TenantId
|
||||
grant.ProjectId = payload.ProjectId
|
||||
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 findGrantResource(userId int, resourceId int, tenantId 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 resource.TenantId != tenantId {
|
||||
return resource, errors.New("resource tenant_id does not match grant tenant_id")
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupResourceControllerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.UsingSQLite = true
|
||||
common.UsingMySQL = false
|
||||
common.UsingPostgreSQL = false
|
||||
common.RedisEnabled = false
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
model.DB = db
|
||||
model.LOG_DB = db
|
||||
require.NoError(t, db.AutoMigrate(&model.ResourceBinding{}, &model.ResourceGrant{}))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func performResourceRequest(handler gin.HandlerFunc, userID int, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
r := gin.New()
|
||||
r.Handle(method, path, func(c *gin.Context) {
|
||||
c.Set("id", userID)
|
||||
handler(c)
|
||||
})
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestCreateResourceStoresMetadataAndSecretRefOnly(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
body := `{
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
"name":"Project repository",
|
||||
"resource_type":"git",
|
||||
"provider":"github",
|
||||
"external_id":"https://example.com/org/repo",
|
||||
"secret_ref":"vault://tenant-a/git/repo",
|
||||
"metadata":{"repo_url":"https://example.com/org/repo","ref":"main","allowed_paths":["."]},
|
||||
"permission_scope":{"actions":["read","write"]},
|
||||
"constraints":{"environment":"dev"}
|
||||
}`
|
||||
|
||||
w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"success":true`)
|
||||
require.Contains(t, w.Body.String(), `"secret_ref":"vault://tenant-a/git/repo"`)
|
||||
|
||||
var resource model.ResourceBinding
|
||||
require.NoError(t, db.First(&resource).Error)
|
||||
require.Equal(t, "git", resource.ResourceType)
|
||||
require.Equal(t, "vault://tenant-a/git/repo", resource.SecretRef)
|
||||
require.NotContains(t, resource.Metadata, "token")
|
||||
require.NotContains(t, resource.PermissionScope, "token")
|
||||
require.NotContains(t, resource.Constraints, "token")
|
||||
}
|
||||
|
||||
func TestCreateResourceRejectsPlaintextSecretKeys(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
body := `{
|
||||
"tenant_id":"tenant-a",
|
||||
"name":"Cloud account",
|
||||
"resource_type":"cloud_account",
|
||||
"metadata":{"account_id":"sub-1","access_key":"do-not-store"},
|
||||
"secret_ref":"vault://tenant-a/cloud/sub-1"
|
||||
}`
|
||||
|
||||
w := performResourceRequest(CreateResource, 7, http.MethodPost, "/", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"success":false`)
|
||||
require.Contains(t, w.Body.String(), "plaintext secrets are not allowed")
|
||||
}
|
||||
|
||||
func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
UserId: 7,
|
||||
TenantId: "tenant-a",
|
||||
ProjectId: "project-a",
|
||||
Name: "SK repo",
|
||||
ResourceType: "sk",
|
||||
Provider: "git",
|
||||
SecretRef: "vault://tenant-a/sk/repo",
|
||||
Metadata: `{"repo_url":"https://example.com/sk.git"}`,
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&resource).Error)
|
||||
|
||||
body := fmt.Sprintf(`{
|
||||
"tenant_id":"tenant-a",
|
||||
"project_id":"project-a",
|
||||
"resource_id":%d,
|
||||
"role":"developer",
|
||||
"agnet_id":"agnet-dev-1",
|
||||
"permission_scope":{"actions":["read"]},
|
||||
"constraints":{"paths":["skills/**"]}
|
||||
}`, resource.Id)
|
||||
|
||||
w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"success":true`)
|
||||
require.Contains(t, w.Body.String(), `"tenant_id":"tenant-a"`)
|
||||
require.Contains(t, w.Body.String(), `"project_id":"project-a"`)
|
||||
require.Contains(t, w.Body.String(), `"role":"developer"`)
|
||||
require.Contains(t, w.Body.String(), `"agnet_id":"agnet-dev-1"`)
|
||||
|
||||
var grant model.ResourceGrant
|
||||
require.NoError(t, db.First(&grant).Error)
|
||||
require.Equal(t, resource.Id, grant.ResourceId)
|
||||
require.Equal(t, "tenant-a", grant.TenantId)
|
||||
require.Equal(t, "project-a", grant.ProjectId)
|
||||
require.Equal(t, "developer", grant.Role)
|
||||
require.Equal(t, "agnet-dev-1", grant.AgnetId)
|
||||
}
|
||||
|
||||
func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) {
|
||||
db := setupResourceControllerTestDB(t)
|
||||
resource := model.ResourceBinding{
|
||||
UserId: 7,
|
||||
TenantId: "tenant-a",
|
||||
Name: "VM",
|
||||
ResourceType: "cloud_resource",
|
||||
Status: "active",
|
||||
}
|
||||
require.NoError(t, db.Create(&resource).Error)
|
||||
|
||||
body := fmt.Sprintf(`{
|
||||
"tenant_id":"tenant-b",
|
||||
"project_id":"project-a",
|
||||
"resource_id":%d,
|
||||
"role":"operator",
|
||||
"agnet_id":"agnet-ops-1"
|
||||
}`, resource.Id)
|
||||
|
||||
w := performResourceRequest(CreateResourceGrant, 7, http.MethodPost, "/", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"success":false`)
|
||||
require.Contains(t, w.Body.String(), "resource tenant_id does not match grant tenant_id")
|
||||
}
|
||||
@@ -330,6 +330,8 @@ func migrateDBFast() error {
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
{&GitSource{}, "GitSource"},
|
||||
{&ResourceBinding{}, "ResourceBinding"},
|
||||
{&ResourceGrant{}, "ResourceGrant"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package model
|
||||
|
||||
// ResourceBinding is the Manager-side resource record described by docs/plan.md P1.
|
||||
// It stores tenant/project resource metadata and a Secret Store reference only;
|
||||
// plaintext credentials must never be stored here.
|
||||
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(64);index"`
|
||||
Name string `json:"name" gorm:"type:varchar(128);not null"`
|
||||
ResourceType string `json:"resource_type" gorm:"type:varchar(32);index;not null"`
|
||||
Provider string `json:"provider" gorm:"type:varchar(64);default:'custom'"`
|
||||
ExternalId string `json:"external_id" gorm:"type:varchar(512)"`
|
||||
SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"`
|
||||
Metadata string `json:"metadata" 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';index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
// ResourceGrant assigns a ResourceBinding to a project, role, and child Agnet.
|
||||
// It is the auditable Manager expression of "tenant/project grants resource to role".
|
||||
type ResourceGrant 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(64);index;not null"`
|
||||
ResourceId int `json:"resource_id" gorm:"index;not null"`
|
||||
Role string `json:"role" gorm:"type:varchar(128);index;not null"`
|
||||
AgnetId string `json:"agnet_id" gorm:"type:varchar(128);index;not null"`
|
||||
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"`
|
||||
RevokedAt int64 `json:"revoked_at" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
@@ -184,6 +184,24 @@ func SetApiRouter(router *gin.Engine) {
|
||||
gitSourceRoute.DELETE("/:id", controller.DeleteGitSource)
|
||||
}
|
||||
|
||||
resourceRoute := apiRouter.Group("/resources")
|
||||
resourceRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
resourceRoute.GET("/", controller.ListResources)
|
||||
resourceRoute.POST("/", controller.CreateResource)
|
||||
resourceRoute.PUT("/:id", controller.UpdateResource)
|
||||
resourceRoute.DELETE("/:id", controller.DeleteResource)
|
||||
}
|
||||
|
||||
resourceGrantRoute := apiRouter.Group("/resource-grants")
|
||||
resourceGrantRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
resourceGrantRoute.GET("/", controller.ListResourceGrants)
|
||||
resourceGrantRoute.POST("/", controller.CreateResourceGrant)
|
||||
resourceGrantRoute.PUT("/:id", controller.UpdateResourceGrant)
|
||||
resourceGrantRoute.DELETE("/:id", controller.DeleteResourceGrant)
|
||||
}
|
||||
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user