feat(xworkmate): add profile secret locators
This commit is contained in:
parent
c827270543
commit
794d386639
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user