omx(team): auto-checkpoint worker-1 [1]

This commit is contained in:
gongzhiyong
2026-05-02 23:32:00 +08:00
parent 05182f0277
commit 24405519a9
4 changed files with 474 additions and 229 deletions
+320 -108
View File
@@ -2,7 +2,9 @@ package controller
import (
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/heicode/manager/common"
@@ -20,19 +22,59 @@ var allowedResourceTypes = map[string]bool{
"cloud_resource": true,
}
type resourceBindingPayload struct {
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"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
ResourceType string `json:"resource_type"`
Provider string `json:"provider"`
ResourceRef string `json:"resource_ref"`
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"`
Secret string `json:"secret"`
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 {
@@ -40,74 +82,47 @@ type resourceGrantPayload struct {
ProjectId string `json:"project_id"`
ResourceId int `json:"resource_id"`
Role string `json:"role"`
SubAgnetId string `json:"sub_agnet_id"`
AgnetId string `json:"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"`
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"`
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 normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingPayload, error) {
func normalizeResourcePayload(p resourcePayload) (resourcePayload, 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.ResourceType = strings.ToLower(strings.TrimSpace(p.ResourceType))
p.Provider = strings.TrimSpace(p.Provider)
p.ResourceRef = strings.TrimSpace(p.ResourceRef)
p.Status = strings.TrimSpace(p.Status)
p.ExternalId = strings.TrimSpace(p.ExternalId)
p.SecretRef = strings.TrimSpace(p.SecretRef)
p.Secret = strings.TrimSpace(p.Secret)
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 !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 p.ResourceRef == "" {
return p, errors.New("resource_ref required")
}
if p.Secret != "" {
return p, errors.New("secret plaintext is not accepted; store credentials in Secret Store and pass secret_ref")
if !allowedResourceTypes[p.ResourceType] {
return p, fmt.Errorf("resource_type must be one of %s", strings.Join(sortedResourceTypes(), ", "))
}
if p.Provider == "" {
p.Provider = "custom"
@@ -115,6 +130,9 @@ func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingP
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{}
}
@@ -124,6 +142,9 @@ func normalizeResourceBindingPayload(p resourceBindingPayload) (resourceBindingP
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
}
@@ -131,9 +152,9 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload
p.TenantId = strings.TrimSpace(p.TenantId)
p.ProjectId = strings.TrimSpace(p.ProjectId)
p.Role = strings.TrimSpace(p.Role)
p.SubAgnetId = strings.TrimSpace(p.SubAgnetId)
p.Status = strings.TrimSpace(p.Status)
p.AuditNote = strings.TrimSpace(p.AuditNote)
p.AgnetId = strings.TrimSpace(p.AgnetId)
p.Status = strings.ToLower(strings.TrimSpace(p.Status))
if p.TenantId == "" {
return p, errors.New("tenant_id required")
}
@@ -146,78 +167,130 @@ func normalizeResourceGrantPayload(p resourceGrantPayload) (resourceGrantPayload
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 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
func sortedResourceTypes() []string {
types := make([]string, 0, len(allowedResourceTypes))
for resourceType := range allowedResourceTypes {
types = append(types, resourceType)
}
permissionBytes, err := common.Marshal(permissionScope)
if err != nil {
return "", "", "", err
}
constraintBytes, err := common.Marshal(constraints)
if err != nil {
return "", "", "", err
}
return string(metadataBytes), string(permissionBytes), string(constraintBytes), nil
sort.Strings(types)
return types
}
func parseMapField(raw string) map[string]any {
out := map[string]any{}
if raw != "" {
_ = common.UnmarshalJsonStr(raw, &out)
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 out
return false
}
func resourceBindingToResponse(resource model.ResourceBinding) resourceBindingResponse {
return resourceBindingResponse{
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,
ResourceType: resource.ResourceType,
Name: resource.Name,
ResourceType: resource.ResourceType,
Provider: resource.Provider,
ResourceRef: resource.ResourceRef,
Metadata: parseMapField(resource.Metadata),
PermissionScope: parseMapField(resource.PermissionScope),
Constraints: parseMapField(resource.Constraints),
Status: resource.Status,
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) resourceGrantResponse {
return resourceGrantResponse{
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,
SubAgnetId: grant.SubAgnetId,
PermissionScope: parseMapField(grant.PermissionScope),
Constraints: parseMapField(grant.Constraints),
AgnetId: grant.AgnetId,
PermissionScope: unmarshalResourceJSON(grant.PermissionScope),
Constraints: unmarshalResourceJSON(grant.Constraints),
Status: grant.Status,
AuditNote: grant.AuditNote,
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) {
@@ -229,31 +302,34 @@ func ListResources(c *gin.Context) {
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([]resourceBindingResponse, 0, len(resources))
items := make([]resourceResponse, 0, len(resources))
for _, resource := range resources {
items = append(items, resourceBindingToResponse(resource))
items = append(items, resourceToResponse(resource))
}
common.ApiSuccess(c, gin.H{"items": items})
}
func CreateResource(c *gin.Context) {
userId := c.GetInt("id")
var payload resourceBindingPayload
var payload resourcePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
payload, err := normalizeResourceBindingPayload(payload)
payload, err := normalizeResourcePayload(payload)
if err != nil {
common.ApiError(c, err)
return
}
metadata, permissionScope, constraints, err := marshalResourceMaps(payload.Metadata, payload.PermissionScope, payload.Constraints)
metadata, permissionScope, constraints, err := marshalResourcePayloadJSON(payload)
if err != nil {
common.ApiError(c, err)
return
@@ -262,21 +338,65 @@ func CreateResource(c *gin.Context) {
UserId: userId,
TenantId: payload.TenantId,
ProjectId: payload.ProjectId,
ResourceType: payload.ResourceType,
Name: payload.Name,
ResourceType: payload.ResourceType,
Provider: payload.Provider,
ResourceRef: payload.ResourceRef,
ExternalId: payload.ExternalId,
SecretRef: payload.SecretRef,
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, resourceBindingToResponse(resource))
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) {
@@ -293,6 +413,22 @@ func DeleteResource(c *gin.Context) {
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)
@@ -302,6 +438,9 @@ func ListResourceGrants(c *gin.Context) {
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)
@@ -309,7 +448,7 @@ func ListResourceGrants(c *gin.Context) {
}
items := make([]resourceGrantResponse, 0, len(grants))
for _, grant := range grants {
items = append(items, resourceGrantToResponse(grant))
items = append(items, resourceGrantToResponse(grant, nil))
}
common.ApiSuccess(c, gin.H{"items": items})
}
@@ -326,16 +465,12 @@ func CreateResourceGrant(c *gin.Context) {
common.ApiError(c, err)
return
}
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")
return
}
resource, err := findGrantResource(userId, payload.ResourceId, payload.TenantId)
if err != nil {
common.ApiError(c, err)
return
}
_, permissionScope, constraints, err := marshalResourceMaps(map[string]any{}, payload.PermissionScope, payload.Constraints)
permissionScope, constraints, err := marshalResourceGrantPayloadJSON(payload)
if err != nil {
common.ApiError(c, err)
return
@@ -346,17 +481,68 @@ func CreateResourceGrant(c *gin.Context) {
ProjectId: payload.ProjectId,
ResourceId: payload.ResourceId,
Role: payload.Role,
SubAgnetId: payload.SubAgnetId,
AgnetId: payload.AgnetId,
PermissionScope: permissionScope,
Constraints: constraints,
Status: payload.Status,
AuditNote: payload.AuditNote,
}
if err := model.DB.Create(&grant).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, resourceGrantToResponse(grant))
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) {
@@ -372,3 +558,29 @@ func DeleteResourceGrant(c *gin.Context) {
}
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
}
+137 -111
View File
@@ -1,140 +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/stretchr/testify/assert"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupResourceControllerTestDB(t *testing.T) {
func setupResourceControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := openTokenControllerTestDB(t)
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 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 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 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 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 TestCreateResourceAcceptsSecretRefOnly(t *testing.T) {
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"
}`
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",
},
"permission_scope": map[string]any{
"actions": []string{"read", "write"},
},
"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)
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")
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 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)
func TestCreateResourceGrantAssignsTenantProjectRoleAgnet(t *testing.T) {
db := setupResourceControllerTestDB(t)
resource := model.ResourceBinding{
UserId: 7,
TenantId: "tenant-a",
ProjectId: "project-a",
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",
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, model.DB.Create(&resource).Error)
require.NoError(t, 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"},
},
"constraints": map[string]any{
"environment": "staging",
},
"audit_note": "grant for deployment smoke test",
}
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/resource-grants/", body, 7)
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)
CreateResourceGrant(ctx)
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"`)
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"])
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")
}
+15 -10
View File
@@ -1,35 +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(128);index;not null"`
ResourceType string `json:"resource_type" gorm:"type:varchar(32);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'"`
ResourceRef string `json:"resource_ref" gorm:"type:varchar(512);not null"`
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'"`
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"`
}
// 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(128);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);not null"`
SubAgnetId string `json:"sub_agnet_id" gorm:"type:varchar(128);index"`
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'"`
AuditNote string `json:"audit_note" 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"`
}
+2
View File
@@ -189,6 +189,7 @@ func SetApiRouter(router *gin.Engine) {
{
resourceRoute.GET("/", controller.ListResources)
resourceRoute.POST("/", controller.CreateResource)
resourceRoute.PUT("/:id", controller.UpdateResource)
resourceRoute.DELETE("/:id", controller.DeleteResource)
}
@@ -197,6 +198,7 @@ func SetApiRouter(router *gin.Engine) {
{
resourceGrantRoute.GET("/", controller.ListResourceGrants)
resourceGrantRoute.POST("/", controller.CreateResourceGrant)
resourceGrantRoute.PUT("/:id", controller.UpdateResourceGrant)
resourceGrantRoute.DELETE("/:id", controller.DeleteResourceGrant)
}