feat: wire resource secrets to OpenBao

Manager needs a platform-owned secret handoff path so resource bindings can keep only vault references while OpenBao stores tenant-scoped credential payloads.

Tested: go test ./controller ./model ./router && go vet ./controller ./model ./router
Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
gongzhiyong
2026-05-03 23:41:55 +08:00
co-authored by OmX
parent a7588e1bc8
commit d9eb7dcd74
5 changed files with 266 additions and 2 deletions
+83
View File
@@ -59,6 +59,10 @@ type resourcePayload struct {
Status string `json:"status"`
}
type resourceSecretPayload struct {
Data map[string]any `json:"data"`
}
type resourceResponse struct {
Id int `json:"id"`
UserId int `json:"user_id"`
@@ -413,6 +417,85 @@ func DeleteResource(c *gin.Context) {
common.ApiSuccess(c, gin.H{"deleted": true})
}
func UpsertResourceSecret(c *gin.Context) {
userId := c.GetInt("id")
var resource model.ResourceBinding
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&resource).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorMsg(c, "resource not found")
return
}
common.ApiError(c, err)
return
}
var payload resourceSecretPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid params"})
return
}
if len(payload.Data) == 0 {
common.ApiErrorMsg(c, "secret data required")
return
}
client, err := newSecretStoreClientFromEnv()
if err != nil {
common.ApiError(c, err)
return
}
secretPath := resourceSecretPath(resource)
if err := client.putKV2(secretPath, payload.Data); err != nil {
common.ApiError(c, err)
return
}
resource.SecretRef = fmt.Sprintf("vault://%s/%s", client.mount, secretPath)
if err := model.DB.Save(&resource).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, gin.H{
"resource": resourceToResponse(resource),
"secret_ref": resource.SecretRef,
})
}
func resourceSecretPath(resource model.ResourceBinding) string {
projectId := resource.ProjectId
if strings.TrimSpace(projectId) == "" {
projectId = "_tenant"
}
return strings.Join([]string{
"tenants",
sanitizeSecretPathSegment(resource.TenantId),
"projects",
sanitizeSecretPathSegment(projectId),
"resources",
fmt.Sprintf("%d", resource.Id),
}, "/")
}
func sanitizeSecretPathSegment(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "_"
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-' || r == '.':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return b.String()
}
func marshalResourcePayloadJSON(payload resourcePayload) (string, string, string, error) {
metadata, err := marshalResourceJSON(payload.Metadata)
if err != nil {