diff --git a/api/xworkmate.go b/api/xworkmate.go index b0184bb..b2dc85e 100644 --- a/api/xworkmate.go +++ b/api/xworkmate.go @@ -26,13 +26,23 @@ type xworkmateAccessContext struct { } type xworkmateProfilePayload struct { - OpenclawURL string `json:"openclawUrl"` - OpenclawOrigin string `json:"openclawOrigin"` - VaultURL string `json:"vaultUrl"` - VaultNamespace string `json:"vaultNamespace"` - VaultSecretPath string `json:"vaultSecretPath"` - VaultSecretKey string `json:"vaultSecretKey"` - ApisixURL string `json:"apisixUrl"` + OpenclawURL string `json:"openclawUrl"` + OpenclawOrigin string `json:"openclawOrigin"` + VaultURL string `json:"vaultUrl"` + VaultNamespace string `json:"vaultNamespace"` + VaultSecretPath string `json:"vaultSecretPath"` + VaultSecretKey string `json:"vaultSecretKey"` + SecretLocators []xworkmateSecretLocatorPayload `json:"secretLocators"` + ApisixURL string `json:"apisixUrl"` +} + +type xworkmateSecretLocatorPayload struct { + ID string `json:"id"` + Provider string `json:"provider"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` + Target string `json:"target"` + Required bool `json:"required"` } var xworkmateForbiddenTokenFields = map[string]struct{}{ @@ -252,13 +262,71 @@ func buildXWorkmateTokenConfigured(profile *store.XWorkmateProfile) gin.H { return result } - if strings.TrimSpace(profile.VaultSecretPath) != "" && strings.TrimSpace(profile.VaultSecretKey) != "" { + if hasOpenclawXWorkmateSecretLocator(profile) { result["openclaw"] = true } return result } +func hasOpenclawXWorkmateSecretLocator(profile *store.XWorkmateProfile) bool { + if profile == nil { + return false + } + + if strings.TrimSpace(profile.VaultSecretPath) != "" && strings.TrimSpace(profile.VaultSecretKey) != "" { + return true + } + for _, locator := range profile.SecretLocators { + if locator.Target != store.XWorkmateSecretLocatorTargetOpenclawGatewayToken { + continue + } + if strings.TrimSpace(locator.SecretPath) != "" && strings.TrimSpace(locator.SecretKey) != "" { + return true + } + } + return false +} + +func buildXWorkmateSecretLocators(profile *store.XWorkmateProfile) []gin.H { + if profile == nil || len(profile.SecretLocators) == 0 { + return []gin.H{} + } + + result := make([]gin.H, 0, len(profile.SecretLocators)) + for _, locator := range profile.SecretLocators { + entry := gin.H{ + "id": locator.ID, + "provider": locator.Provider, + "secretPath": locator.SecretPath, + "secretKey": locator.SecretKey, + "target": locator.Target, + "required": locator.Required, + } + result = append(result, entry) + } + return result +} + +func buildStoreXWorkmateSecretLocators(locators []xworkmateSecretLocatorPayload) []store.XWorkmateSecretLocator { + if len(locators) == 0 { + return []store.XWorkmateSecretLocator{} + } + + result := make([]store.XWorkmateSecretLocator, 0, len(locators)) + for _, locator := range locators { + result = append(result, store.XWorkmateSecretLocator{ + ID: locator.ID, + Provider: locator.Provider, + SecretPath: locator.SecretPath, + SecretKey: locator.SecretKey, + Target: locator.Target, + Required: locator.Required, + }) + } + return result +} + func (h *handler) buildSessionUser(ctx context.Context, host string, user *store.User) (gin.H, error) { access, err := h.resolveXWorkmateAccess(ctx, host, user) if err != nil { @@ -284,6 +352,7 @@ func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *stor "vaultNamespace": "", "vaultSecretPath": "", "vaultSecretKey": "", + "secretLocators": []gin.H{}, "apisixUrl": "", } if profile != nil { @@ -293,6 +362,7 @@ func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *stor resolvedProfile["vaultNamespace"] = profile.VaultNamespace resolvedProfile["vaultSecretPath"] = profile.VaultSecretPath resolvedProfile["vaultSecretKey"] = profile.VaultSecretKey + resolvedProfile["secretLocators"] = buildXWorkmateSecretLocators(profile) resolvedProfile["apisixUrl"] = profile.ApisixURL } @@ -447,6 +517,7 @@ func (h *handler) updateXWorkmateProfile(c *gin.Context) { VaultNamespace: payload.VaultNamespace, VaultSecretPath: payload.VaultSecretPath, VaultSecretKey: payload.VaultSecretKey, + SecretLocators: buildStoreXWorkmateSecretLocators(payload.SecretLocators), ApisixURL: payload.ApisixURL, } if err := h.store.UpsertXWorkmateProfile(c.Request.Context(), profile); err != nil { diff --git a/api/xworkmate_test.go b/api/xworkmate_test.go index 694d33e..3adbec9 100644 --- a/api/xworkmate_test.go +++ b/api/xworkmate_test.go @@ -54,7 +54,7 @@ func newXWorkmateTestHarness(t *testing.T) (*gin.Engine, *store.User, string) { return router, user, token } -func TestBuildXWorkmateTokenConfiguredUsesSecretLocator(t *testing.T) { +func TestBuildXWorkmateTokenConfiguredUsesSecretLocators(t *testing.T) { t.Parallel() tests := []struct { @@ -71,13 +71,40 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocator(t *testing.T) { }, }, { - name: "path and key mark openclaw configured", + name: "legacy path and key mark openclaw configured", profile: &store.XWorkmateProfile{ VaultSecretPath: "kv/openclaw", VaultSecretKey: "token", }, openclaw: true, }, + { + name: "explicit openclaw locator marks openclaw configured", + profile: &store.XWorkmateProfile{ + SecretLocators: []store.XWorkmateSecretLocator{ + { + Provider: "vault", + SecretPath: "kv/openclaw", + SecretKey: "token", + Target: store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, + }, + }, + }, + openclaw: true, + }, + { + name: "other locator stays false", + profile: &store.XWorkmateProfile{ + SecretLocators: []store.XWorkmateSecretLocator{ + { + Provider: "vault", + SecretPath: "kv/ai", + SecretKey: "token", + Target: store.XWorkmateSecretLocatorTargetAIGatewayAccessToken, + }, + }, + }, + }, { name: "blank profile stays false", profile: &store.XWorkmateProfile{}, @@ -103,12 +130,117 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocator(t *testing.T) { } } -func TestGetXWorkmateProfileReportsLocatorBackedTokenState(t *testing.T) { +func TestUpdateAndGetXWorkmateProfileRoundTripsSecretLocators(t *testing.T) { + gin.SetMode(gin.TestMode) + + router, _, token := newXWorkmateTestHarness(t) + body, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "openclawUrl": "wss://gateway.example.com", + "openclawOrigin": "https://gateway.example.com", + "vaultUrl": "https://vault.example.com", + "vaultNamespace": "team-a", + "secretLocators": []map[string]any{ + { + "id": "locator-openclaw", + "provider": "vault", + "secretPath": "kv/openclaw", + "secretKey": "token", + "target": store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, + "required": true, + }, + { + "id": "locator-ai-gateway", + "provider": "vault", + "secretPath": "kv/ai", + "secretKey": "access-token", + "target": store.XWorkmateSecretLocatorTargetAIGatewayAccessToken, + }, + }, + "apisixUrl": "https://apigw.example.com", + }, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + + putReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(body)) + putReq.Header.Set("Content-Type", "application/json") + putReq.Header.Set("Authorization", "Bearer "+token) + putReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + putRec := httptest.NewRecorder() + router.ServeHTTP(putRec, putReq) + if putRec.Code != http.StatusOK { + t.Fatalf("expected update success, got %d: %s", putRec.Code, putRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil) + getReq.Header.Set("Authorization", "Bearer "+token) + getReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + getRec := httptest.NewRecorder() + router.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("expected profile fetch success, got %d: %s", getRec.Code, getRec.Body.String()) + } + + var resp struct { + Profile struct { + OpenclawURL string `json:"openclawUrl"` + OpenclawOrigin string `json:"openclawOrigin"` + VaultURL string `json:"vaultUrl"` + VaultNamespace string `json:"vaultNamespace"` + SecretLocators []struct { + ID string `json:"id"` + Provider string `json:"provider"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` + Target string `json:"target"` + Required bool `json:"required"` + } `json:"secretLocators"` + VaultSecretPath string `json:"vaultSecretPath"` + VaultSecretKey string `json:"vaultSecretKey"` + ApisixURL string `json:"apisixUrl"` + } `json:"profile"` + TokenConfigured struct { + Openclaw bool `json:"openclaw"` + Vault bool `json:"vault"` + Apisix bool `json:"apisix"` + } `json:"tokenConfigured"` + } + if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode profile response: %v", err) + } + + if resp.Profile.VaultSecretPath != "kv/openclaw" || resp.Profile.VaultSecretKey != "token" { + t.Fatalf("expected compatibility fields to mirror openclaw locator, got %#v", resp.Profile) + } + if len(resp.Profile.SecretLocators) != 2 { + t.Fatalf("expected 2 locators, got %#v", resp.Profile.SecretLocators) + } + if resp.Profile.SecretLocators[0].ID != "locator-openclaw" || !resp.Profile.SecretLocators[0].Required { + t.Fatalf("expected openclaw locator to round-trip, got %#v", resp.Profile.SecretLocators[0]) + } + if resp.Profile.SecretLocators[0].Target != store.XWorkmateSecretLocatorTargetOpenclawGatewayToken { + t.Fatalf("expected openclaw target, got %#v", resp.Profile.SecretLocators[0]) + } + if resp.Profile.SecretLocators[1].Target != store.XWorkmateSecretLocatorTargetAIGatewayAccessToken { + t.Fatalf("expected ai gateway target, got %#v", resp.Profile.SecretLocators[1]) + } + if !resp.TokenConfigured.Openclaw { + t.Fatalf("expected openclaw tokenConfigured=true when locator and key are present") + } + if resp.TokenConfigured.Vault { + t.Fatalf("expected vault tokenConfigured=false without a vault-backed token locator") + } + if resp.TokenConfigured.Apisix { + t.Fatalf("expected apisix tokenConfigured=false without a token locator") + } +} + +func TestUpdateXWorkmateProfileSynthesizesSecretLocatorsFromLegacyFields(t *testing.T) { gin.SetMode(gin.TestMode) router, _, token := newXWorkmateTestHarness(t) - // Rebuild the profile in the route store through an update request so the - // handler path matches the production write flow. body, err := json.Marshal(map[string]any{ "profile": map[string]any{ "openclawUrl": "wss://gateway.example.com", @@ -145,35 +277,28 @@ func TestGetXWorkmateProfileReportsLocatorBackedTokenState(t *testing.T) { var resp struct { Profile struct { - OpenclawURL string `json:"openclawUrl"` - OpenclawOrigin string `json:"openclawOrigin"` - VaultURL string `json:"vaultUrl"` - VaultNamespace string `json:"vaultNamespace"` + SecretLocators []struct { + Provider string `json:"provider"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` + Target string `json:"target"` + } `json:"secretLocators"` VaultSecretPath string `json:"vaultSecretPath"` VaultSecretKey string `json:"vaultSecretKey"` - ApisixURL string `json:"apisixUrl"` } `json:"profile"` - TokenConfigured struct { - Openclaw bool `json:"openclaw"` - Vault bool `json:"vault"` - Apisix bool `json:"apisix"` - } `json:"tokenConfigured"` } if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode profile response: %v", err) } + if len(resp.Profile.SecretLocators) != 1 { + t.Fatalf("expected synthesized single locator, got %#v", resp.Profile.SecretLocators) + } + if resp.Profile.SecretLocators[0].Provider != "vault" || resp.Profile.SecretLocators[0].Target != store.XWorkmateSecretLocatorTargetOpenclawGatewayToken { + t.Fatalf("expected synthesized openclaw vault locator, got %#v", resp.Profile.SecretLocators[0]) + } if resp.Profile.VaultSecretPath != "kv/openclaw" || resp.Profile.VaultSecretKey != "token" { - t.Fatalf("expected locator fields to round-trip, got %#v", resp.Profile) - } - if !resp.TokenConfigured.Openclaw { - t.Fatalf("expected openclaw tokenConfigured=true when locator and key are present") - } - if resp.TokenConfigured.Vault { - t.Fatalf("expected vault tokenConfigured=false without a vault-backed token locator") - } - if resp.TokenConfigured.Apisix { - t.Fatalf("expected apisix tokenConfigured=false without a token locator") + t.Fatalf("expected legacy fields to remain readable, got %#v", resp.Profile) } } diff --git a/internal/model/xworkmate_tenant.go b/internal/model/xworkmate_tenant.go index 0d7a0a9..e971bf3 100644 --- a/internal/model/xworkmate_tenant.go +++ b/internal/model/xworkmate_tenant.go @@ -56,19 +56,29 @@ type TenantMembership struct { func (TenantMembership) TableName() string { return "tenant_memberships" } type XWorkmateProfile struct { - ID string `gorm:"column:id;type:text;primaryKey"` - TenantID string `gorm:"column:tenant_id;type:text;not null;uniqueIndex:idx_xworkmate_profiles_scope"` - UserID string `gorm:"column:user_id;type:text;not null;default:'';uniqueIndex:idx_xworkmate_profiles_scope"` - Scope string `gorm:"column:scope;type:text;not null;uniqueIndex:idx_xworkmate_profiles_scope"` - OpenclawURL string `gorm:"column:openclaw_url;type:text;not null;default:''"` - OpenclawOrigin string `gorm:"column:openclaw_origin;type:text;not null;default:''"` - VaultURL string `gorm:"column:vault_url;type:text;not null;default:''"` - VaultNamespace string `gorm:"column:vault_namespace;type:text;not null;default:''"` - VaultSecretPath string `gorm:"column:vault_secret_path;type:text;not null;default:''"` - VaultSecretKey string `gorm:"column:vault_secret_key;type:text;not null;default:''"` - ApisixURL string `gorm:"column:apisix_url;type:text;not null;default:''"` - CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime"` + ID string `gorm:"column:id;type:text;primaryKey"` + TenantID string `gorm:"column:tenant_id;type:text;not null;uniqueIndex:idx_xworkmate_profiles_scope"` + UserID string `gorm:"column:user_id;type:text;not null;default:'';uniqueIndex:idx_xworkmate_profiles_scope"` + Scope string `gorm:"column:scope;type:text;not null;uniqueIndex:idx_xworkmate_profiles_scope"` + OpenclawURL string `gorm:"column:openclaw_url;type:text;not null;default:''"` + OpenclawOrigin string `gorm:"column:openclaw_origin;type:text;not null;default:''"` + VaultURL string `gorm:"column:vault_url;type:text;not null;default:''"` + VaultNamespace string `gorm:"column:vault_namespace;type:text;not null;default:''"` + VaultSecretPath string `gorm:"column:vault_secret_path;type:text;not null;default:''"` + VaultSecretKey string `gorm:"column:vault_secret_key;type:text;not null;default:''"` + SecretLocators []XWorkmateSecretLocator `gorm:"column:secret_locators;type:text;not null;serializer:json;default:'[]'"` + ApisixURL string `gorm:"column:apisix_url;type:text;not null;default:''"` + CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime"` +} + +type XWorkmateSecretLocator struct { + ID string `json:"id"` + Provider string `json:"provider"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` + Target string `json:"target"` + Required bool `json:"required"` } func (XWorkmateProfile) TableName() string { return "xworkmate_profiles" } diff --git a/internal/store/xworkmate.go b/internal/store/xworkmate.go index 48557f1..c8e789a 100644 --- a/internal/store/xworkmate.go +++ b/internal/store/xworkmate.go @@ -10,6 +10,8 @@ import ( "net/url" "strings" "time" + + "github.com/google/uuid" ) const ( @@ -28,6 +30,12 @@ const ( XWorkmateProfileScopeTenantShared = "tenant-shared" XWorkmateProfileScopeUserPrivate = "user-private" + XWorkmateSecretLocatorProviderVault = "vault" + + XWorkmateSecretLocatorTargetOpenclawGatewayToken = "openclaw.gateway_token" + XWorkmateSecretLocatorTargetAIGatewayAccessToken = "ai_gateway.access_token" + XWorkmateSecretLocatorTargetOllamaCloudAPIKey = "ollama_cloud.api_key" + SharedXWorkmateTenantID = "svc-plus-xworkmate" SharedXWorkmateTenantName = "svc.plus XWorkmate" SharedXWorkmateDomain = "svc.plus" @@ -80,11 +88,21 @@ type XWorkmateProfile struct { VaultNamespace string VaultSecretPath string VaultSecretKey string + SecretLocators []XWorkmateSecretLocator ApisixURL string CreatedAt time.Time UpdatedAt time.Time } +type XWorkmateSecretLocator struct { + ID string `json:"id"` + Provider string `json:"provider"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` + Target string `json:"target"` + Required bool `json:"required"` +} + func NormalizeTenantEdition(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case SharedPublicTenantEdition: @@ -130,6 +148,68 @@ func NormalizeXWorkmateProfileScope(value string) string { } } +func NormalizeXWorkmateSecretLocator(locator *XWorkmateSecretLocator) { + if locator == nil { + return + } + + locator.ID = strings.TrimSpace(locator.ID) + if locator.ID == "" { + locator.ID = uuid.NewString() + } + locator.Provider = strings.ToLower(strings.TrimSpace(locator.Provider)) + if locator.Provider == "" { + locator.Provider = XWorkmateSecretLocatorProviderVault + } + locator.SecretPath = strings.Trim(strings.TrimSpace(locator.SecretPath), "/") + locator.SecretKey = strings.TrimSpace(locator.SecretKey) + locator.Target = strings.ToLower(strings.TrimSpace(locator.Target)) +} + +func cloneXWorkmateSecretLocators(locators []XWorkmateSecretLocator) []XWorkmateSecretLocator { + if len(locators) == 0 { + return []XWorkmateSecretLocator{} + } + + cloned := make([]XWorkmateSecretLocator, len(locators)) + copy(cloned, locators) + return cloned +} + +func legacyXWorkmateSecretLocatorID(profile *XWorkmateProfile) string { + if profile == nil { + return "legacy|xworkmate|openclaw.gateway_token" + } + + return strings.Join([]string{ + "legacy", + strings.TrimSpace(profile.TenantID), + strings.TrimSpace(profile.UserID), + NormalizeXWorkmateProfileScope(profile.Scope), + XWorkmateSecretLocatorTargetOpenclawGatewayToken, + }, "|") +} + +func synthesizeXWorkmateSecretLocatorFromLegacy(profile *XWorkmateProfile) XWorkmateSecretLocator { + return XWorkmateSecretLocator{ + ID: legacyXWorkmateSecretLocatorID(profile), + Provider: XWorkmateSecretLocatorProviderVault, + SecretPath: profile.VaultSecretPath, + SecretKey: profile.VaultSecretKey, + Target: XWorkmateSecretLocatorTargetOpenclawGatewayToken, + } +} + +func compatibilityXWorkmateSecretLocator(locators []XWorkmateSecretLocator) (string, string, bool) { + for _, locator := range locators { + if locator.Target == XWorkmateSecretLocatorTargetOpenclawGatewayToken && + locator.SecretPath != "" && locator.SecretKey != "" { + return locator.SecretPath, locator.SecretKey, true + } + } + return "", "", false +} + func NormalizeHostname(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -217,6 +297,26 @@ func NormalizeXWorkmateProfile(profile *XWorkmateProfile) { profile.VaultNamespace = strings.TrimSpace(profile.VaultNamespace) profile.VaultSecretPath = strings.Trim(strings.TrimSpace(profile.VaultSecretPath), "/") profile.VaultSecretKey = strings.TrimSpace(profile.VaultSecretKey) + profile.SecretLocators = cloneXWorkmateSecretLocators(profile.SecretLocators) + for i := range profile.SecretLocators { + NormalizeXWorkmateSecretLocator(&profile.SecretLocators[i]) + } + if len(profile.SecretLocators) == 0 && profile.VaultSecretPath != "" && profile.VaultSecretKey != "" { + profile.SecretLocators = []XWorkmateSecretLocator{synthesizeXWorkmateSecretLocatorFromLegacy(profile)} + } + if (profile.VaultSecretPath == "" || profile.VaultSecretKey == "") && len(profile.SecretLocators) > 0 { + if secretPath, secretKey, ok := compatibilityXWorkmateSecretLocator(profile.SecretLocators); ok { + if profile.VaultSecretPath == "" { + profile.VaultSecretPath = secretPath + } + if profile.VaultSecretKey == "" { + profile.VaultSecretKey = secretKey + } + } + } + if profile.SecretLocators == nil { + profile.SecretLocators = []XWorkmateSecretLocator{} + } profile.ApisixURL = strings.TrimSpace(profile.ApisixURL) } diff --git a/internal/store/xworkmate_memory.go b/internal/store/xworkmate_memory.go index cca4ec3..8927f1f 100644 --- a/internal/store/xworkmate_memory.go +++ b/internal/store/xworkmate_memory.go @@ -258,6 +258,8 @@ func (s *memoryStore) GetXWorkmateProfile(ctx context.Context, tenantID, userID, } entry := *profile + entry.SecretLocators = cloneXWorkmateSecretLocators(entry.SecretLocators) + NormalizeXWorkmateProfile(&entry) return &entry, nil } @@ -294,6 +296,7 @@ func (s *memoryStore) UpsertXWorkmateProfile(ctx context.Context, profile *XWork existing.VaultNamespace = profile.VaultNamespace existing.VaultSecretPath = profile.VaultSecretPath existing.VaultSecretKey = profile.VaultSecretKey + existing.SecretLocators = cloneXWorkmateSecretLocators(profile.SecretLocators) existing.ApisixURL = profile.ApisixURL existing.UpdatedAt = now profile.CreatedAt = existing.CreatedAt @@ -312,6 +315,7 @@ func (s *memoryStore) UpsertXWorkmateProfile(ctx context.Context, profile *XWork VaultNamespace: profile.VaultNamespace, VaultSecretPath: profile.VaultSecretPath, VaultSecretKey: profile.VaultSecretKey, + SecretLocators: cloneXWorkmateSecretLocators(profile.SecretLocators), ApisixURL: profile.ApisixURL, CreatedAt: now, UpdatedAt: now, diff --git a/internal/store/xworkmate_postgres.go b/internal/store/xworkmate_postgres.go index b977a12..4409e7f 100644 --- a/internal/store/xworkmate_postgres.go +++ b/internal/store/xworkmate_postgres.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "encoding/json" "errors" "strings" @@ -224,7 +225,8 @@ LIMIT 1` func (s *postgresStore) GetXWorkmateProfile(ctx context.Context, tenantID, userID, scope string) (*XWorkmateProfile, error) { profile := &XWorkmateProfile{} - query := `SELECT id, tenant_id, user_id, scope, openclaw_url, openclaw_origin, vault_url, vault_namespace, vault_secret_path, vault_secret_key, apisix_url, created_at, updated_at + var secretLocatorsJSON string + query := `SELECT id, tenant_id, user_id, scope, openclaw_url, openclaw_origin, vault_url, vault_namespace, vault_secret_path, vault_secret_key, COALESCE(secret_locators, '[]'), apisix_url, created_at, updated_at FROM xworkmate_profiles WHERE tenant_id = $1 AND user_id = $2 AND scope = $3 LIMIT 1` @@ -246,6 +248,7 @@ LIMIT 1` &profile.VaultNamespace, &profile.VaultSecretPath, &profile.VaultSecretKey, + &secretLocatorsJSON, &profile.ApisixURL, &profile.CreatedAt, &profile.UpdatedAt, @@ -256,6 +259,10 @@ LIMIT 1` return nil, err } + if err := json.Unmarshal([]byte(secretLocatorsJSON), &profile.SecretLocators); err != nil { + return nil, err + } + NormalizeXWorkmateProfile(profile) return profile, nil } @@ -269,10 +276,15 @@ func (s *postgresStore) UpsertXWorkmateProfile(ctx context.Context, profile *XWo profile.ID = uuid.NewString() } + locatorsJSON, err := json.Marshal(profile.SecretLocators) + if err != nil { + return err + } + query := `INSERT INTO xworkmate_profiles ( - id, tenant_id, user_id, scope, openclaw_url, openclaw_origin, vault_url, vault_namespace, vault_secret_path, vault_secret_key, apisix_url, created_at, updated_at + id, tenant_id, user_id, scope, openclaw_url, openclaw_origin, vault_url, vault_namespace, vault_secret_path, vault_secret_key, secret_locators, apisix_url, created_at, updated_at ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), now()) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now()) ON CONFLICT (tenant_id, user_id, scope) DO UPDATE SET openclaw_url = EXCLUDED.openclaw_url, openclaw_origin = EXCLUDED.openclaw_origin, @@ -280,6 +292,7 @@ SET openclaw_url = EXCLUDED.openclaw_url, vault_namespace = EXCLUDED.vault_namespace, vault_secret_path = EXCLUDED.vault_secret_path, vault_secret_key = EXCLUDED.vault_secret_key, + secret_locators = EXCLUDED.secret_locators, apisix_url = EXCLUDED.apisix_url, updated_at = now() RETURNING created_at, updated_at` @@ -297,6 +310,7 @@ RETURNING created_at, updated_at` profile.VaultNamespace, profile.VaultSecretPath, profile.VaultSecretKey, + string(locatorsJSON), profile.ApisixURL, ).Scan(&profile.CreatedAt, &profile.UpdatedAt) } diff --git a/internal/store/xworkmate_test.go b/internal/store/xworkmate_test.go index d5e753d..ab10f49 100644 --- a/internal/store/xworkmate_test.go +++ b/internal/store/xworkmate_test.go @@ -89,6 +89,7 @@ func TestMemoryStoreResolveTenantAndProfile(t *testing.T) { Scope: XWorkmateProfileScopeUserPrivate, OpenclawURL: "wss://openclaw.tenant-one.svc.plus", VaultSecretPath: "kv/openclaw", + VaultSecretKey: "token", }); err != nil { t.Fatalf("upsert private profile: %v", err) } @@ -111,6 +112,21 @@ func TestMemoryStoreResolveTenantAndProfile(t *testing.T) { if profile.OpenclawURL != "wss://openclaw.tenant-one.svc.plus" { t.Fatalf("expected persisted openclaw url, got %q", profile.OpenclawURL) } + if profile.VaultSecretPath != "kv/openclaw" || profile.VaultSecretKey != "token" { + t.Fatalf("expected legacy secret fields to round-trip, got %#v", profile) + } + if len(profile.SecretLocators) != 1 { + t.Fatalf("expected synthesized secret locator, got %#v", profile.SecretLocators) + } + if profile.SecretLocators[0].Provider != XWorkmateSecretLocatorProviderVault { + t.Fatalf("expected vault provider, got %#v", profile.SecretLocators[0]) + } + if profile.SecretLocators[0].Target != XWorkmateSecretLocatorTargetOpenclawGatewayToken { + t.Fatalf("expected openclaw target, got %#v", profile.SecretLocators[0]) + } + if profile.SecretLocators[0].SecretPath != "kv/openclaw" || profile.SecretLocators[0].SecretKey != "token" { + t.Fatalf("expected synthesized secret locator path/key, got %#v", profile.SecretLocators[0]) + } memberships, err := st.ListTenantMembershipsByUser(ctx, "user-1") if err != nil { @@ -123,3 +139,66 @@ func TestMemoryStoreResolveTenantAndProfile(t *testing.T) { t.Fatalf("expected tenant name to be populated, got %q", memberships[0].TenantName) } } + +func TestMemoryStorePersistsExplicitSecretLocators(t *testing.T) { + ctx := context.Background() + st := NewMemoryStore() + + if err := st.EnsureTenant(ctx, &Tenant{ + ID: "tenant-locator-1", + Name: "Tenant Locator", + Edition: TenantPrivateEdition, + }); err != nil { + t.Fatalf("ensure tenant: %v", err) + } + + locators := []XWorkmateSecretLocator{ + { + ID: "locator-openclaw", + Provider: "vault", + SecretPath: "kv/openclaw", + SecretKey: "gateway-token", + Target: XWorkmateSecretLocatorTargetOpenclawGatewayToken, + Required: true, + }, + { + ID: "locator-ai-gateway", + Provider: "vault", + SecretPath: "kv/ai", + SecretKey: "access-token", + Target: XWorkmateSecretLocatorTargetAIGatewayAccessToken, + }, + } + if err := st.UpsertXWorkmateProfile(ctx, &XWorkmateProfile{ + TenantID: "tenant-locator-1", + UserID: "user-2", + Scope: XWorkmateProfileScopeUserPrivate, + VaultURL: "https://vault.example.com", + VaultNamespace: "team-locators", + SecretLocators: locators, + }); err != nil { + t.Fatalf("upsert profile: %v", err) + } + + profile, err := st.GetXWorkmateProfile(ctx, "tenant-locator-1", "user-2", XWorkmateProfileScopeUserPrivate) + if err != nil { + t.Fatalf("get profile: %v", err) + } + + if len(profile.SecretLocators) != len(locators) { + t.Fatalf("expected %d locators, got %#v", len(locators), profile.SecretLocators) + } + for i := range locators { + if profile.SecretLocators[i].ID != locators[i].ID || + profile.SecretLocators[i].Provider != locators[i].Provider || + profile.SecretLocators[i].SecretPath != locators[i].SecretPath || + profile.SecretLocators[i].SecretKey != locators[i].SecretKey || + profile.SecretLocators[i].Target != locators[i].Target || + profile.SecretLocators[i].Required != locators[i].Required { + t.Fatalf("locator %d mismatch: got %#v want %#v", i, profile.SecretLocators[i], locators[i]) + } + } + if profile.VaultSecretPath != "kv/openclaw" || profile.VaultSecretKey != "gateway-token" { + t.Fatalf("expected openclaw locator to back legacy fields, got %#v", profile) + } +}