diff --git a/heicode/controller/resource.go b/heicode/controller/resource.go index b2a8aa9..d71c8f3 100644 --- a/heicode/controller/resource.go +++ b/heicode/controller/resource.go @@ -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 { diff --git a/heicode/controller/resource_test.go b/heicode/controller/resource_test.go index a7cdeca..e41cfdf 100644 --- a/heicode/controller/resource_test.go +++ b/heicode/controller/resource_test.go @@ -41,12 +41,16 @@ func setupResourceControllerTestDB(t *testing.T) *gorm.DB { } func performResourceRequest(handler gin.HandlerFunc, userID int, method string, path string, body string) *httptest.ResponseRecorder { + return performResourceRequestWithRoute(handler, userID, method, path, path, body) +} + +func performResourceRequestWithRoute(handler gin.HandlerFunc, userID int, method string, routePath string, requestPath string, body string) *httptest.ResponseRecorder { r := gin.New() - r.Handle(method, path, func(c *gin.Context) { + r.Handle(method, routePath, func(c *gin.Context) { c.Set("id", userID) handler(c) }) - req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -164,3 +168,69 @@ func TestCreateResourceGrantRejectsCrossTenantResource(t *testing.T) { require.Contains(t, w.Body.String(), `"success":false`) require.Contains(t, w.Body.String(), "resource tenant_id does not match grant tenant_id") } + +func TestUpsertResourceSecretWritesOpenBaoAndStoresOnlySecretRef(t *testing.T) { + db := setupResourceControllerTestDB(t) + resource := model.ResourceBinding{ + UserId: 7, + TenantId: "tenant-a", + ProjectId: "project-a", + Name: "GitHub", + ResourceType: "git", + Provider: "github", + Status: "active", + } + require.NoError(t, db.Create(&resource).Error) + + var writtenPath string + var writtenBody map[string]map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/v1/secret/data/tenants/tenant-a/projects/project-a/resources/1", r.URL.Path) + require.Equal(t, "manager-token", r.Header.Get("X-Vault-Token")) + require.NoError(t, common.DecodeJson(r.Body, &writtenBody)) + writtenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"version":1}}`)) + })) + defer server.Close() + t.Setenv("OPENBAO_ADDR", server.URL) + t.Setenv("OPENBAO_TOKEN", "manager-token") + t.Setenv("OPENBAO_KV_MOUNT", "secret") + + body := `{"data":{"access_token":"do-not-echo","username":"octo"}}` + w := performResourceRequestWithRoute(UpsertResourceSecret, 7, http.MethodPost, "/:id/secret", "/1/secret", 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://secret/tenants/tenant-a/projects/project-a/resources/1"`) + require.NotContains(t, w.Body.String(), "do-not-echo") + require.Equal(t, "/v1/secret/data/tenants/tenant-a/projects/project-a/resources/1", writtenPath) + require.Equal(t, "do-not-echo", writtenBody["data"]["access_token"]) + + var stored model.ResourceBinding + require.NoError(t, db.First(&stored, resource.Id).Error) + require.Equal(t, "vault://secret/tenants/tenant-a/projects/project-a/resources/1", stored.SecretRef) + require.NotContains(t, stored.Metadata, "do-not-echo") +} + +func TestUpsertResourceSecretRequiresSecretStoreToken(t *testing.T) { + db := setupResourceControllerTestDB(t) + resource := model.ResourceBinding{ + UserId: 7, + TenantId: "tenant-a", + Name: "GitHub", + ResourceType: "git", + Status: "active", + } + require.NoError(t, db.Create(&resource).Error) + t.Setenv("OPENBAO_ADDR", "http://127.0.0.1:8200") + t.Setenv("OPENBAO_TOKEN", "") + t.Setenv("VAULT_TOKEN", "") + t.Setenv("OPENBAO_TOKEN_FILE", "") + t.Setenv("VAULT_TOKEN_FILE", "") + + w := performResourceRequestWithRoute(UpsertResourceSecret, 7, http.MethodPost, "/:id/secret", "/1/secret", `{"data":{"token":"x"}}`) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"success":false`) + require.Contains(t, w.Body.String(), "secret store token is not configured") +} diff --git a/heicode/controller/secret_store.go b/heicode/controller/secret_store.go new file mode 100644 index 0000000..e78529f --- /dev/null +++ b/heicode/controller/secret_store.go @@ -0,0 +1,104 @@ +package controller + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/heicode/manager/common" +) + +type secretStoreClient struct { + address string + mount string + token string + client *http.Client +} + +func newSecretStoreClientFromEnv() (secretStoreClient, error) { + address := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENBAO_ADDR")), "/") + if address == "" { + address = strings.TrimRight(strings.TrimSpace(os.Getenv("VAULT_ADDR")), "/") + } + if address == "" { + address = "http://127.0.0.1:8200" + } + token := strings.TrimSpace(os.Getenv("OPENBAO_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("VAULT_TOKEN")) + } + if token == "" { + tokenFile := strings.TrimSpace(os.Getenv("OPENBAO_TOKEN_FILE")) + if tokenFile == "" { + tokenFile = strings.TrimSpace(os.Getenv("VAULT_TOKEN_FILE")) + } + if tokenFile != "" { + data, err := os.ReadFile(tokenFile) + if err != nil { + return secretStoreClient{}, fmt.Errorf("failed to read secret store token file: %w", err) + } + token = strings.TrimSpace(string(data)) + } + } + if token == "" { + return secretStoreClient{}, errors.New("secret store token is not configured") + } + mount := strings.Trim(strings.TrimSpace(os.Getenv("OPENBAO_KV_MOUNT")), "/") + if mount == "" { + mount = strings.Trim(strings.TrimSpace(os.Getenv("VAULT_KV_MOUNT")), "/") + } + if mount == "" { + mount = "secret" + } + return secretStoreClient{ + address: address, + mount: mount, + token: token, + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (s secretStoreClient) putKV2(path string, data map[string]any) error { + if len(data) == 0 { + return errors.New("secret data required") + } + body, err := common.Marshal(map[string]any{"data": data}) + if err != nil { + return err + } + url := fmt.Sprintf("%s/v1/%s/data/%s", s.address, s.mount, strings.Trim(path, "/")) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Vault-Token", s.token) + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + message := readSecretStoreError(resp.Body) + if message == "" { + message = resp.Status + } + return fmt.Errorf("secret store write failed: %s", message) + } + return nil +} + +func readSecretStoreError(body io.Reader) string { + var payload struct { + Errors []string `json:"errors"` + } + if err := common.DecodeJson(body, &payload); err != nil { + return "" + } + return strings.Join(payload.Errors, "; ") +} diff --git a/heicode/docker-compose.override.yml b/heicode/docker-compose.override.yml index 3520d96..0f241b7 100644 --- a/heicode/docker-compose.override.yml +++ b/heicode/docker-compose.override.yml @@ -11,6 +11,12 @@ services: heicode: env_file: - ${ENV_FILE:-.env} + environment: + OPENBAO_ADDR: ${OPENBAO_ADDR:-http://heicode-openbao:8200} + OPENBAO_KV_MOUNT: ${OPENBAO_KV_MOUNT:-secret} + OPENBAO_TOKEN_FILE: ${OPENBAO_TOKEN_FILE:-/run/secrets/openbao-manager-token} + volumes: + - ${OPENBAO_MANAGER_TOKEN_FILE:-/root/heicode-secrets/openbao-manager-token.txt}:/run/secrets/openbao-manager-token:ro image: ${IMAGE_TAG:-heicode-manager:local} build: context: . diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 8d25e2b..d9df80a 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -189,6 +189,7 @@ func SetApiRouter(router *gin.Engine) { { resourceRoute.GET("/", controller.ListResources) resourceRoute.POST("/", controller.CreateResource) + resourceRoute.POST("/:id/secret", controller.UpsertResourceSecret) resourceRoute.PUT("/:id", controller.UpdateResource) resourceRoute.DELETE("/:id", controller.DeleteResource) }