feat(xworkmate): add profile secret locators

This commit is contained in:
Haitao Pan 2026-03-30 19:35:46 +08:00
parent c827270543
commit 794d386639
7 changed files with 452 additions and 49 deletions

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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" }

View File

@ -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)
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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)
}
}