feat(accounts): add vault-backed xworkmate secrets

This commit is contained in:
Haitao Pan 2026-04-02 09:14:19 +08:00
parent e9fb4af72b
commit c98688cd51
9 changed files with 1496 additions and 27 deletions

View File

@ -80,6 +80,7 @@ type handler struct {
oauthProviders map[string]auth.OAuthProvider
oauthFrontendURL string
publicURL string
xworkmateVaultService xworkmateVaultService
xrayConfigRenderer func(*store.User) (string, string, []string, error)
agentRegistry agentRegistry
db *gorm.DB
@ -240,6 +241,14 @@ func WithOAuthFrontendURL(url string) Option {
}
}
// WithXWorkmateVaultService configures the Vault-backed secret service used by
// xworkmate integration endpoints.
func WithXWorkmateVaultService(vaultService xworkmateVaultService) Option {
return func(h *handler) {
h.xworkmateVaultService = vaultService
}
}
// WithAgentRegistry configures the handler with the provided agent registry.
func WithAgentRegistry(registry agentRegistry) Option {
return func(h *handler) {
@ -349,6 +358,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.DELETE("/session", h.deleteSession)
authProtected.GET("/xworkmate/profile", h.getXWorkmateProfile)
authProtected.PUT("/xworkmate/profile", h.updateXWorkmateProfile)
authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets)
authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret)
authProtected.DELETE("/xworkmate/secrets/:target", h.deleteXWorkmateSecret)
authProtected.POST("/mfa/totp/provision", h.provisionTOTP)
authProtected.POST("/mfa/totp/verify", h.verifyTOTP)

View File

@ -178,7 +178,9 @@ func (h *handler) currentAuthenticatedUser(c *gin.Context) (*store.User, bool) {
func (h *handler) ensureSharedTenantMembership(ctx context.Context, user *store.User) (string, error) {
role := store.TenantMembershipRoleUser
if h.isRootAccount(user) || strings.EqualFold(strings.TrimSpace(user.Role), store.RoleAdmin) {
if h.isRootAccount(user) ||
strings.EqualFold(strings.TrimSpace(user.Role), store.RoleAdmin) ||
strings.EqualFold(strings.TrimSpace(user.Role), store.RoleOperator) {
role = store.TenantMembershipRoleAdmin
}
return role, h.store.UpsertTenantMembership(ctx, &store.TenantMembership{
@ -269,6 +271,25 @@ func buildXWorkmateTokenConfigured(profile *store.XWorkmateProfile) gin.H {
return result
}
func buildXWorkmateTokenConfiguredWithVaultStatus(profile *store.XWorkmateProfile, vaultStatus map[string]bool) gin.H {
result := buildXWorkmateTokenConfigured(profile)
if len(vaultStatus) == 0 {
return result
}
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetOpenclawGatewayToken]; ok {
result["openclaw"] = configured
}
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetVaultRootToken]; ok {
result["vault"] = configured
}
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetAIGatewayAccessToken]; ok {
result["apisix"] = configured
}
return result
}
func hasOpenclawXWorkmateSecretLocator(profile *store.XWorkmateProfile) bool {
if profile == nil {
return false
@ -344,7 +365,7 @@ func (h *handler) buildSessionUser(ctx context.Context, host string, user *store
return payload, nil
}
func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *store.XWorkmateProfile) gin.H {
func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *store.XWorkmateProfile, tokenConfigured gin.H) gin.H {
resolvedProfile := gin.H{
"openclawUrl": "",
"openclawOrigin": "",
@ -378,10 +399,166 @@ func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *stor
"canEditIntegrations": access.CanEditIntegrations,
"canManageTenant": access.CanManageTenant,
"profile": resolvedProfile,
"tokenConfigured": buildXWorkmateTokenConfigured(profile),
"tokenConfigured": tokenConfigured,
}
}
func resolvedXWorkmateProfileUserID(access *xworkmateAccessContext, user *store.User) string {
if access == nil {
return ""
}
if access.ProfileScope == store.XWorkmateProfileScopeTenantShared {
return ""
}
if user == nil {
return ""
}
return strings.TrimSpace(user.ID)
}
func (h *handler) loadXWorkmateProfile(ctx context.Context, access *xworkmateAccessContext, user *store.User) (*store.XWorkmateProfile, error) {
profileUserID := resolvedXWorkmateProfileUserID(access, user)
profile, err := h.store.GetXWorkmateProfile(ctx, access.Tenant.ID, profileUserID, access.ProfileScope)
if err == nil {
return profile, nil
}
if !errors.Is(err, store.ErrXWorkmateProfileNotFound) {
return nil, err
}
if access.ProfileScope != store.XWorkmateProfileScopeTenantShared {
return nil, nil
}
legacyProfile, legacyErr := h.store.GetXWorkmateProfile(ctx, access.Tenant.ID, strings.TrimSpace(user.ID), access.ProfileScope)
if legacyErr != nil {
if errors.Is(legacyErr, store.ErrXWorkmateProfileNotFound) {
return nil, nil
}
return nil, legacyErr
}
return legacyProfile, nil
}
func (h *handler) ensureXWorkmateVaultService(c *gin.Context) bool {
if h.xworkmateVaultService != nil {
return true
}
respondError(c, http.StatusServiceUnavailable, "xworkmate_vault_unavailable", "xworkmate vault integration is not configured")
return false
}
func findStoredXWorkmateSecretLocator(profile *store.XWorkmateProfile, target string) (store.XWorkmateSecretLocator, bool) {
if profile == nil {
return store.XWorkmateSecretLocator{}, false
}
normalizedTarget := strings.ToLower(strings.TrimSpace(target))
for _, locator := range profile.SecretLocators {
if locator.Target != normalizedTarget {
continue
}
if strings.TrimSpace(locator.SecretPath) == "" || strings.TrimSpace(locator.SecretKey) == "" {
continue
}
store.NormalizeXWorkmateSecretLocator(&locator)
return locator, true
}
return store.XWorkmateSecretLocator{}, false
}
func upsertXWorkmateSecretLocator(profile *store.XWorkmateProfile, locator store.XWorkmateSecretLocator) {
if profile == nil {
return
}
store.NormalizeXWorkmateSecretLocator(&locator)
for i := range profile.SecretLocators {
if profile.SecretLocators[i].Target != locator.Target {
continue
}
profile.SecretLocators[i].ID = locator.ID
profile.SecretLocators[i].Provider = locator.Provider
profile.SecretLocators[i].SecretPath = locator.SecretPath
profile.SecretLocators[i].SecretKey = locator.SecretKey
profile.SecretLocators[i].Required = locator.Required
store.NormalizeXWorkmateProfile(profile)
return
}
profile.SecretLocators = append(profile.SecretLocators, locator)
store.NormalizeXWorkmateProfile(profile)
}
func buildXWorkmateSecretStatusPayload(locator store.XWorkmateSecretLocator, configured bool) gin.H {
managedTarget, _ := findXWorkmateManagedTarget(locator.Target)
return gin.H{
"target": locator.Target,
"configured": configured,
"state": func() string {
if configured {
return "configured"
}
return "missing"
}(),
"required": managedTarget.Required || locator.Required,
"locator": gin.H{
"id": locator.ID,
"provider": locator.Provider,
"secretPath": locator.SecretPath,
"secretKey": locator.SecretKey,
"target": locator.Target,
"required": managedTarget.Required || locator.Required,
},
}
}
func (h *handler) describeXWorkmateSecrets(ctx context.Context, access *xworkmateAccessContext, user *store.User, profile *store.XWorkmateProfile) ([]gin.H, map[string]bool, error) {
profileUserID := resolvedXWorkmateProfileUserID(access, user)
secrets := make([]gin.H, 0, len(xworkmateManagedSecretTargets))
statusByTarget := make(map[string]bool, len(xworkmateManagedSecretTargets))
for _, managedTarget := range xworkmateManagedSecretTargets {
locator, ok := findStoredXWorkmateSecretLocator(profile, managedTarget.Target)
if !ok {
var err error
locator, err = buildManagedXWorkmateSecretLocator(access, profileUserID, managedTarget.Target)
if err != nil {
return nil, nil, err
}
}
configured := false
if h.xworkmateVaultService != nil {
var err error
configured, err = h.xworkmateVaultService.HasSecret(ctx, locator)
if err != nil {
return nil, nil, err
}
} else {
configured = statusByTargetFromMetadata(profile, managedTarget.Target)
}
statusByTarget[managedTarget.Target] = configured
secrets = append(secrets, buildXWorkmateSecretStatusPayload(locator, configured))
}
return secrets, statusByTarget, nil
}
func statusByTargetFromMetadata(profile *store.XWorkmateProfile, target string) bool {
if profile == nil {
return false
}
if target == store.XWorkmateSecretLocatorTargetOpenclawGatewayToken {
return hasOpenclawXWorkmateSecretLocator(profile)
}
for _, locator := range profile.SecretLocators {
if locator.Target == strings.ToLower(strings.TrimSpace(target)) &&
strings.TrimSpace(locator.SecretPath) != "" &&
strings.TrimSpace(locator.SecretKey) != "" {
return true
}
}
return false
}
func containsForbiddenXWorkmateTokenField(value any) bool {
switch typed := value.(type) {
case map[string]any:
@ -423,26 +600,23 @@ func (h *handler) getXWorkmateProfile(c *gin.Context) {
return
}
profile, err := h.store.GetXWorkmateProfile(c.Request.Context(), access.Tenant.ID, user.ID, access.ProfileScope)
if err != nil && !errors.Is(err, store.ErrXWorkmateProfileNotFound) {
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
return
}
if errors.Is(err, store.ErrXWorkmateProfileNotFound) {
profile = nil
}
if access.ProfileScope == store.XWorkmateProfileScopeTenantShared && profile == nil {
profile, err = h.store.GetXWorkmateProfile(c.Request.Context(), access.Tenant.ID, "", access.ProfileScope)
if err != nil && !errors.Is(err, store.ErrXWorkmateProfileNotFound) {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
tokenConfigured := buildXWorkmateTokenConfigured(profile)
if h.xworkmateVaultService != nil {
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
return
}
if errors.Is(err, store.ErrXWorkmateProfileNotFound) {
profile = nil
}
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
}
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile))
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile, tokenConfigured))
}
func (h *handler) updateXWorkmateProfile(c *gin.Context) {
@ -502,10 +676,7 @@ func (h *handler) updateXWorkmateProfile(c *gin.Context) {
return
}
profileUserID := user.ID
if access.ProfileScope == store.XWorkmateProfileScopeTenantShared {
profileUserID = ""
}
profileUserID := resolvedXWorkmateProfileUserID(access, user)
profile := &store.XWorkmateProfile{
TenantID: access.Tenant.ID,
@ -525,7 +696,227 @@ func (h *handler) updateXWorkmateProfile(c *gin.Context) {
return
}
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile))
tokenConfigured := buildXWorkmateTokenConfigured(profile)
if h.xworkmateVaultService != nil {
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
return
}
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
}
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, profile, tokenConfigured))
}
func (h *handler) getXWorkmateSecrets(c *gin.Context) {
if !h.ensureXWorkmateVaultService(c) {
return
}
user, ok := h.currentAuthenticatedUser(c)
if !ok {
return
}
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
if err != nil {
if errors.Is(err, store.ErrTenantMembershipNotFound) {
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
return
}
if errors.Is(err, store.ErrTenantNotFound) {
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
return
}
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
return
}
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
return
}
secrets, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
return
}
c.JSON(http.StatusOK, gin.H{
"edition": access.Tenant.Edition,
"profileScope": access.ProfileScope,
"membershipRole": access.MembershipRole,
"canEditIntegrations": access.CanEditIntegrations,
"canManageTenant": access.CanManageTenant,
"tenant": gin.H{"id": access.Tenant.ID, "name": access.Tenant.Name, "domain": access.Domain},
"secrets": secrets,
"tokenConfigured": buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget),
"vaultBackendEnabled": true,
})
}
func (h *handler) putXWorkmateSecret(c *gin.Context) {
if !h.ensureXWorkmateVaultService(c) {
return
}
user, ok := h.currentAuthenticatedUser(c)
if !ok {
return
}
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
if err != nil {
if errors.Is(err, store.ErrTenantMembershipNotFound) {
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
return
}
if errors.Is(err, store.ErrTenantNotFound) {
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
return
}
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
return
}
if !access.CanEditIntegrations {
respondError(c, http.StatusForbidden, "xworkmate_secret_forbidden", "you are not allowed to update integrations for this tenant")
return
}
if h.isReadOnlyAccount(user) {
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
return
}
target := strings.ToLower(strings.TrimSpace(c.Param("target")))
if _, ok := findXWorkmateManagedTarget(target); !ok {
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
return
}
var payload struct {
Value string `json:"value"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
if strings.TrimSpace(payload.Value) == "" {
respondError(c, http.StatusBadRequest, "xworkmate_secret_value_required", "secret value is required")
return
}
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
return
}
if profile == nil {
profile = &store.XWorkmateProfile{
TenantID: access.Tenant.ID,
UserID: resolvedXWorkmateProfileUserID(access, user),
Scope: access.ProfileScope,
}
}
locator, err := buildManagedXWorkmateSecretLocator(access, resolvedXWorkmateProfileUserID(access, user), target)
if err != nil {
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
return
}
if err := h.xworkmateVaultService.WriteSecret(c.Request.Context(), locator, payload.Value); err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_write_failed", "failed to persist xworkmate secret")
return
}
upsertXWorkmateSecretLocator(profile, locator)
if err := h.store.UpsertXWorkmateProfile(c.Request.Context(), profile); err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_write_failed", "failed to save xworkmate profile")
return
}
c.JSON(http.StatusOK, gin.H{
"secret": buildXWorkmateSecretStatusPayload(locator, true),
"profileScope": access.ProfileScope,
"tokenConfigured": buildXWorkmateTokenConfiguredWithVaultStatus(profile, map[string]bool{target: true}),
})
}
func (h *handler) deleteXWorkmateSecret(c *gin.Context) {
if !h.ensureXWorkmateVaultService(c) {
return
}
user, ok := h.currentAuthenticatedUser(c)
if !ok {
return
}
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
if err != nil {
if errors.Is(err, store.ErrTenantMembershipNotFound) {
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
return
}
if errors.Is(err, store.ErrTenantNotFound) {
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
return
}
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
return
}
if !access.CanEditIntegrations {
respondError(c, http.StatusForbidden, "xworkmate_secret_forbidden", "you are not allowed to update integrations for this tenant")
return
}
if h.isReadOnlyAccount(user) {
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
return
}
target := strings.ToLower(strings.TrimSpace(c.Param("target")))
if _, ok := findXWorkmateManagedTarget(target); !ok {
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
return
}
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
return
}
locator, ok := findStoredXWorkmateSecretLocator(profile, target)
if !ok {
locator, err = buildManagedXWorkmateSecretLocator(access, resolvedXWorkmateProfileUserID(access, user), target)
if err != nil {
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
return
}
}
if err := h.xworkmateVaultService.DeleteSecret(c.Request.Context(), locator); err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_delete_failed", "failed to delete xworkmate secret")
return
}
tokenConfigured := buildXWorkmateTokenConfigured(profile)
if h.xworkmateVaultService != nil {
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
if err != nil {
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
return
}
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
}
c.JSON(http.StatusOK, gin.H{
"secret": buildXWorkmateSecretStatusPayload(locator, false),
"profileScope": access.ProfileScope,
"tokenConfigured": tokenConfigured,
})
}
func (h *handler) bootstrapTenant(c *gin.Context) {

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@ -17,16 +18,37 @@ import (
func newXWorkmateTestHarness(t *testing.T) (*gin.Engine, *store.User, string) {
t.Helper()
ctx := context.Background()
st := store.NewMemoryStore()
user := &store.User{
return newXWorkmateTestHarnessForUser(t, &store.User{
Name: "XWorkmate Admin",
Email: "xworkmate-admin@example.com",
EmailVerified: true,
Role: store.RoleAdmin,
Level: store.LevelAdmin,
Active: true,
})
}
func newXWorkmateTestHarnessForUser(t *testing.T, user *store.User) (*gin.Engine, *store.User, string) {
t.Helper()
vaultService := newMemoryXWorkmateVaultService()
return newXWorkmateTestHarnessWithVault(t, user, vaultService)
}
func newXWorkmateTestHarnessWithVault(t *testing.T, user *store.User, vaultService xworkmateVaultService) (*gin.Engine, *store.User, string) {
t.Helper()
ctx := context.Background()
st := store.NewMemoryStore()
if user == nil {
user = &store.User{
Name: "XWorkmate Admin",
Email: "xworkmate-admin@example.com",
EmailVerified: true,
Role: store.RoleAdmin,
Level: store.LevelAdmin,
Active: true,
}
}
if err := st.CreateUser(ctx, user); err != nil {
t.Fatalf("create user: %v", err)
@ -50,6 +72,7 @@ func newXWorkmateTestHarness(t *testing.T) (*gin.Engine, *store.User, string) {
RefreshExpiry: time.Hour,
Store: st,
})),
WithXWorkmateVaultService(vaultService),
)
return router, user, token
}
@ -226,8 +249,8 @@ func TestUpdateAndGetXWorkmateProfileRoundTripsSecretLocators(t *testing.T) {
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.Openclaw {
t.Fatalf("expected openclaw tokenConfigured=false until a vault-backed secret exists")
}
if resp.TokenConfigured.Vault {
t.Fatalf("expected vault tokenConfigured=false without a vault-backed token locator")
@ -340,3 +363,311 @@ func TestUpdateXWorkmateProfileRejectsNestedRawTokenFields(t *testing.T) {
t.Fatalf("expected token_persistence_forbidden, got %q", resp.Error)
}
}
func TestXWorkmateSecretsWriteReadDeleteAndKeepLocatorMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _, token := newXWorkmateTestHarness(t)
profileBody, 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",
"apisixUrl": "https://apigw.example.com",
},
})
if err != nil {
t.Fatalf("marshal profile payload: %v", err)
}
putProfileReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
putProfileReq.Header.Set("Content-Type", "application/json")
putProfileReq.Header.Set("Authorization", "Bearer "+token)
putProfileReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
putProfileRec := httptest.NewRecorder()
router.ServeHTTP(putProfileRec, putProfileReq)
if putProfileRec.Code != http.StatusOK {
t.Fatalf("expected profile update success, got %d: %s", putProfileRec.Code, putProfileRec.Body.String())
}
for _, target := range []string{
store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
store.XWorkmateSecretLocatorTargetVaultRootToken,
store.XWorkmateSecretLocatorTargetAIGatewayAccessToken,
} {
secretBody, err := json.Marshal(map[string]any{"value": "super-secret-" + target})
if err != nil {
t.Fatalf("marshal secret payload for %s: %v", target, err)
}
req := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/secrets/"+target, bytes.NewReader(secretBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected secret write success for %s, got %d: %s", target, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "super-secret-"+target) {
t.Fatalf("expected raw secret to stay out of response for %s, got %s", target, rec.Body.String())
}
}
getSecretsReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/secrets", nil)
getSecretsReq.Header.Set("Authorization", "Bearer "+token)
getSecretsReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
getSecretsRec := httptest.NewRecorder()
router.ServeHTTP(getSecretsRec, getSecretsReq)
if getSecretsRec.Code != http.StatusOK {
t.Fatalf("expected secret status fetch success, got %d: %s", getSecretsRec.Code, getSecretsRec.Body.String())
}
if strings.Contains(getSecretsRec.Body.String(), "super-secret-") {
t.Fatalf("expected secret status response to hide raw values, got %s", getSecretsRec.Body.String())
}
getProfileReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getProfileReq.Header.Set("Authorization", "Bearer "+token)
getProfileReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
getProfileRec := httptest.NewRecorder()
router.ServeHTTP(getProfileRec, getProfileReq)
if getProfileRec.Code != http.StatusOK {
t.Fatalf("expected profile fetch success, got %d: %s", getProfileRec.Code, getProfileRec.Body.String())
}
var profileResp struct {
Profile struct {
VaultSecretPath string `json:"vaultSecretPath"`
VaultSecretKey string `json:"vaultSecretKey"`
SecretLocators []struct {
Target string `json:"target"`
} `json:"secretLocators"`
} `json:"profile"`
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getProfileRec.Body.Bytes(), &profileResp); err != nil {
t.Fatalf("decode profile response: %v", err)
}
if !profileResp.TokenConfigured.Openclaw || !profileResp.TokenConfigured.Vault || !profileResp.TokenConfigured.Apisix {
t.Fatalf("expected all synced tokenConfigured fields true, got %#v", profileResp.TokenConfigured)
}
if len(profileResp.Profile.SecretLocators) != 3 {
t.Fatalf("expected 3 secret locators after vault writes, got %#v", profileResp.Profile.SecretLocators)
}
if profileResp.Profile.VaultSecretPath == "" || profileResp.Profile.VaultSecretKey == "" {
t.Fatalf("expected openclaw legacy compatibility fields to remain readable, got %#v", profileResp.Profile)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/auth/xworkmate/secrets/"+store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, nil)
deleteReq.Header.Set("Authorization", "Bearer "+token)
deleteReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected secret delete success, got %d: %s", deleteRec.Code, deleteRec.Body.String())
}
getProfileAfterDeleteReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getProfileAfterDeleteReq.Header.Set("Authorization", "Bearer "+token)
getProfileAfterDeleteReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
getProfileAfterDeleteRec := httptest.NewRecorder()
router.ServeHTTP(getProfileAfterDeleteRec, getProfileAfterDeleteReq)
if getProfileAfterDeleteRec.Code != http.StatusOK {
t.Fatalf("expected profile fetch after delete success, got %d: %s", getProfileAfterDeleteRec.Code, getProfileAfterDeleteRec.Body.String())
}
var afterDeleteResp struct {
Profile struct {
SecretLocators []struct {
Target string `json:"target"`
} `json:"secretLocators"`
} `json:"profile"`
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getProfileAfterDeleteRec.Body.Bytes(), &afterDeleteResp); err != nil {
t.Fatalf("decode post-delete profile response: %v", err)
}
if afterDeleteResp.TokenConfigured.Openclaw {
t.Fatalf("expected deleted openclaw secret to report missing, got %#v", afterDeleteResp.TokenConfigured)
}
if !afterDeleteResp.TokenConfigured.Vault || !afterDeleteResp.TokenConfigured.Apisix {
t.Fatalf("expected unrelated secret statuses to remain true, got %#v", afterDeleteResp.TokenConfigured)
}
if len(afterDeleteResp.Profile.SecretLocators) != 3 {
t.Fatalf("expected locator metadata to remain after delete, got %#v", afterDeleteResp.Profile.SecretLocators)
}
}
func TestXWorkmateSharedSecretsRequireAdminMembershipForWrites(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _, token := newXWorkmateTestHarnessForUser(t, &store.User{
Name: "Shared Demo User",
Email: "shared-user@example.com",
EmailVerified: true,
Role: store.RoleUser,
Level: store.LevelUser,
Active: true,
})
body, err := json.Marshal(map[string]any{"value": "super-secret"})
if err != nil {
t.Fatalf("marshal secret payload: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/secrets/"+store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected shared tenant secret write to be forbidden for non-admin, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestXWorkmatePrivateSecretsAreScopedPerUser(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := context.Background()
st := store.NewMemoryStore()
vaultService := newMemoryXWorkmateVaultService()
tenant := &store.Tenant{
ID: "tenant-private-1",
Name: "Tenant Private 1",
Edition: store.TenantPrivateEdition,
}
if err := st.EnsureTenant(ctx, tenant); err != nil {
t.Fatalf("ensure tenant: %v", err)
}
if err := st.EnsureTenantDomain(ctx, &store.TenantDomain{
TenantID: tenant.ID,
Domain: "tenant-private-1.svc.plus",
Kind: store.TenantDomainKindGenerated,
IsPrimary: true,
Status: store.TenantDomainStatusVerified,
}); err != nil {
t.Fatalf("ensure tenant domain: %v", err)
}
userA := &store.User{
Name: "Tenant Admin A",
Email: "tenant-admin-a@example.com",
EmailVerified: true,
Role: store.RoleAdmin,
Level: store.LevelAdmin,
Active: true,
}
userB := &store.User{
Name: "Tenant Admin B",
Email: "tenant-admin-b@example.com",
EmailVerified: true,
Role: store.RoleAdmin,
Level: store.LevelAdmin,
Active: true,
}
for _, user := range []*store.User{userA, userB} {
if err := st.CreateUser(ctx, user); err != nil {
t.Fatalf("create user %s: %v", user.Email, err)
}
if err := st.UpsertTenantMembership(ctx, &store.TenantMembership{
TenantID: tenant.ID,
UserID: user.ID,
Role: store.TenantMembershipRoleAdmin,
}); err != nil {
t.Fatalf("upsert tenant membership for %s: %v", user.Email, err)
}
}
tokenA := "tenant-token-a"
tokenB := "tenant-token-b"
if err := st.CreateSession(ctx, tokenA, userA.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("create session A: %v", err)
}
if err := st.CreateSession(ctx, tokenB, userB.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("create session B: %v", err)
}
router := gin.New()
RegisterRoutes(
router,
WithStore(st),
WithEmailVerification(false),
WithTokenService(auth.NewTokenService(auth.TokenConfig{
PublicToken: "public-token",
RefreshSecret: "refresh-secret",
AccessSecret: "access-secret",
AccessExpiry: time.Hour,
RefreshExpiry: time.Hour,
Store: st,
})),
WithXWorkmateVaultService(vaultService),
)
body, err := json.Marshal(map[string]any{"value": "tenant-secret-a"})
if err != nil {
t.Fatalf("marshal secret payload: %v", err)
}
writeReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/secrets/"+store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, bytes.NewReader(body))
writeReq.Header.Set("Content-Type", "application/json")
writeReq.Header.Set("Authorization", "Bearer "+tokenA)
writeReq.Header.Set("X-Forwarded-Host", "tenant-private-1.svc.plus")
writeRec := httptest.NewRecorder()
router.ServeHTTP(writeRec, writeReq)
if writeRec.Code != http.StatusOK {
t.Fatalf("expected user A secret write success, got %d: %s", writeRec.Code, writeRec.Body.String())
}
getAReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getAReq.Header.Set("Authorization", "Bearer "+tokenA)
getAReq.Header.Set("X-Forwarded-Host", "tenant-private-1.svc.plus")
getARec := httptest.NewRecorder()
router.ServeHTTP(getARec, getAReq)
if getARec.Code != http.StatusOK {
t.Fatalf("expected user A profile fetch success, got %d: %s", getARec.Code, getARec.Body.String())
}
getBReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getBReq.Header.Set("Authorization", "Bearer "+tokenB)
getBReq.Header.Set("X-Forwarded-Host", "tenant-private-1.svc.plus")
getBRec := httptest.NewRecorder()
router.ServeHTTP(getBRec, getBReq)
if getBRec.Code != http.StatusOK {
t.Fatalf("expected user B profile fetch success, got %d: %s", getBRec.Code, getBRec.Body.String())
}
var userAResp struct {
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getARec.Body.Bytes(), &userAResp); err != nil {
t.Fatalf("decode user A profile response: %v", err)
}
if !userAResp.TokenConfigured.Openclaw {
t.Fatalf("expected user A secret to be configured, got %#v", userAResp.TokenConfigured)
}
var userBResp struct {
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getBRec.Body.Bytes(), &userBResp); err != nil {
t.Fatalf("decode user B profile response: %v", err)
}
if userBResp.TokenConfigured.Openclaw {
t.Fatalf("expected user B to remain isolated from user A secret, got %#v", userBResp.TokenConfigured)
}
}

351
api/xworkmate_vault.go Normal file
View File

@ -0,0 +1,351 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"account/internal/store"
)
const defaultXWorkmateVaultTimeout = 5 * time.Second
type xworkmateVaultService interface {
WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error
DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error
HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error)
}
type XWorkmateVaultConfig struct {
Address string
Token string
Namespace string
Mount string
Timeout time.Duration
HTTPClient *http.Client
}
type httpXWorkmateVaultService struct {
baseURL string
token string
namespace string
mount string
client *http.Client
}
type memoryXWorkmateVaultService struct {
mu sync.RWMutex
store map[string]map[string]string
}
type xworkmateManagedSecretTarget struct {
Target string
Required bool
TokenConfiguredID string
}
var xworkmateManagedSecretTargets = []xworkmateManagedSecretTarget{
{
Target: store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
Required: true,
TokenConfiguredID: "openclaw",
},
{
Target: store.XWorkmateSecretLocatorTargetVaultRootToken,
Required: false,
TokenConfiguredID: "vault",
},
{
Target: store.XWorkmateSecretLocatorTargetAIGatewayAccessToken,
Required: false,
TokenConfiguredID: "apisix",
},
{
Target: store.XWorkmateSecretLocatorTargetOllamaCloudAPIKey,
Required: false,
TokenConfiguredID: "",
},
}
func newMemoryXWorkmateVaultService() *memoryXWorkmateVaultService {
return &memoryXWorkmateVaultService{
store: make(map[string]map[string]string),
}
}
func NewXWorkmateVaultService(cfg XWorkmateVaultConfig) (xworkmateVaultService, error) {
address := strings.TrimSpace(cfg.Address)
token := strings.TrimSpace(cfg.Token)
if address == "" || token == "" {
return nil, nil
}
parsed, err := url.Parse(address)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid xworkmate vault address: %q", address)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultXWorkmateVaultTimeout
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: timeout}
}
mount := strings.Trim(strings.TrimSpace(cfg.Mount), "/")
if mount == "" {
mount = "secret"
}
return &httpXWorkmateVaultService{
baseURL: strings.TrimRight(parsed.String(), "/"),
token: token,
namespace: strings.TrimSpace(cfg.Namespace),
mount: mount,
client: client,
}, nil
}
func (s *memoryXWorkmateVaultService) WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error {
_ = ctx
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return fmt.Errorf("vault locator is incomplete")
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("secret value is required")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.store[locator.SecretPath] == nil {
s.store[locator.SecretPath] = make(map[string]string)
}
s.store[locator.SecretPath][locator.SecretKey] = value
return nil
}
func (s *memoryXWorkmateVaultService) DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error {
_ = ctx
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
secretMap := s.store[locator.SecretPath]
if secretMap == nil {
return nil
}
delete(secretMap, locator.SecretKey)
if len(secretMap) == 0 {
delete(s.store, locator.SecretPath)
}
return nil
}
func (s *memoryXWorkmateVaultService) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) {
_ = ctx
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
secretMap := s.store[locator.SecretPath]
if secretMap == nil {
return false, nil
}
_, ok := secretMap[locator.SecretKey]
return ok, nil
}
func (s *httpXWorkmateVaultService) WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error {
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return fmt.Errorf("vault locator is incomplete")
}
data, err := s.readSecretMap(ctx, locator.SecretPath)
if err != nil {
return err
}
if data == nil {
data = make(map[string]string)
}
data[locator.SecretKey] = value
body, err := json.Marshal(map[string]any{
"data": data,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.dataURL(locator.SecretPath), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return s.do(req, nil)
}
func (s *httpXWorkmateVaultService) DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error {
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return nil
}
data, err := s.readSecretMap(ctx, locator.SecretPath)
if err != nil {
return err
}
if len(data) == 0 {
return nil
}
delete(data, locator.SecretKey)
if len(data) == 0 {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.metadataURL(locator.SecretPath), nil)
if err != nil {
return err
}
return s.do(req, nil)
}
body, err := json.Marshal(map[string]any{
"data": data,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.dataURL(locator.SecretPath), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return s.do(req, nil)
}
func (s *httpXWorkmateVaultService) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) {
store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" {
return false, nil
}
data, err := s.readSecretMap(ctx, locator.SecretPath)
if err != nil {
return false, err
}
_, ok := data[locator.SecretKey]
return ok, nil
}
func (s *httpXWorkmateVaultService) readSecretMap(ctx context.Context, secretPath string) (map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.dataURL(secretPath), nil)
if err != nil {
return nil, err
}
var payload struct {
Data struct {
Data map[string]string `json:"data"`
} `json:"data"`
}
if err := s.do(req, &payload); err != nil {
if strings.Contains(err.Error(), "vault status 404") {
return map[string]string{}, nil
}
return nil, err
}
if payload.Data.Data == nil {
return map[string]string{}, nil
}
return payload.Data.Data, nil
}
func (s *httpXWorkmateVaultService) do(req *http.Request, out any) error {
req.Header.Set("X-Vault-Token", s.token)
if s.namespace != "" {
req.Header.Set("X-Vault-Namespace", s.namespace)
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("vault status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (s *httpXWorkmateVaultService) dataURL(secretPath string) string {
return fmt.Sprintf("%s/v1/%s/data/%s", s.baseURL, s.mount, strings.Trim(strings.TrimSpace(secretPath), "/"))
}
func (s *httpXWorkmateVaultService) metadataURL(secretPath string) string {
return fmt.Sprintf("%s/v1/%s/metadata/%s", s.baseURL, s.mount, strings.Trim(strings.TrimSpace(secretPath), "/"))
}
func findXWorkmateManagedTarget(target string) (xworkmateManagedSecretTarget, bool) {
normalized := strings.ToLower(strings.TrimSpace(target))
for _, candidate := range xworkmateManagedSecretTargets {
if candidate.Target == normalized {
return candidate, true
}
}
return xworkmateManagedSecretTarget{}, false
}
func buildManagedXWorkmateSecretLocator(access *xworkmateAccessContext, userID, target string) (store.XWorkmateSecretLocator, error) {
managedTarget, ok := findXWorkmateManagedTarget(target)
if !ok {
return store.XWorkmateSecretLocator{}, fmt.Errorf("unknown xworkmate secret target: %s", target)
}
if access == nil || access.Tenant == nil {
return store.XWorkmateSecretLocator{}, fmt.Errorf("xworkmate access context is required")
}
path := fmt.Sprintf("xworkmate/tenants/%s/shared", access.Tenant.ID)
if access.ProfileScope == store.XWorkmateProfileScopeUserPrivate {
trimmedUserID := strings.TrimSpace(userID)
if trimmedUserID == "" {
return store.XWorkmateSecretLocator{}, fmt.Errorf("xworkmate private scope user id is required")
}
path = fmt.Sprintf("xworkmate/tenants/%s/users/%s", access.Tenant.ID, trimmedUserID)
}
locator := store.XWorkmateSecretLocator{
ID: strings.Join([]string{"managed", access.Tenant.ID, strings.TrimSpace(userID), access.ProfileScope, managedTarget.Target}, "|"),
Provider: store.XWorkmateSecretLocatorProviderVault,
SecretPath: path,
SecretKey: managedTarget.Target,
Target: managedTarget.Target,
Required: managedTarget.Required,
}
store.NormalizeXWorkmateSecretLocator(&locator)
return locator, nil
}

View File

@ -0,0 +1,357 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"account/internal/auth"
"account/internal/store"
)
func TestXWorkmateVaultLiveIntegration(t *testing.T) {
vaultAddr := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_ADDR"))
vaultToken := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_TOKEN"))
vaultMount := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_MOUNT"))
if vaultAddr == "" || vaultToken == "" {
t.Skip("live vault integration requires XWORKMATE_VAULT_ADDR and XWORKMATE_VAULT_TOKEN")
}
if vaultMount == "" {
vaultMount = "kv"
}
vaultService, err := NewXWorkmateVaultService(XWorkmateVaultConfig{
Address: vaultAddr,
Token: vaultToken,
Namespace: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_NAMESPACE")),
Mount: vaultMount,
})
if err != nil {
t.Fatalf("create vault service: %v", err)
}
router, _, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService)
profileBody, err := json.Marshal(map[string]any{
"profile": map[string]any{
"openclawUrl": "wss://gateway.example.com",
"openclawOrigin": "https://gateway.example.com",
"vaultUrl": vaultAddr,
"vaultNamespace": strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_NAMESPACE")),
"apisixUrl": "https://apigw.example.com",
},
})
if err != nil {
t.Fatalf("marshal profile payload: %v", err)
}
putProfileReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
putProfileReq.Header.Set("Content-Type", "application/json")
putProfileReq.Header.Set("Authorization", "Bearer "+token)
putProfileReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
putProfileRec := httptest.NewRecorder()
router.ServeHTTP(putProfileRec, putProfileReq)
if putProfileRec.Code != http.StatusOK {
t.Fatalf("expected profile update success, got %d: %s", putProfileRec.Code, putProfileRec.Body.String())
}
targets := []string{
store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
store.XWorkmateSecretLocatorTargetVaultRootToken,
store.XWorkmateSecretLocatorTargetAIGatewayAccessToken,
}
secretValuePrefix := "live-vault-check-" + time.Now().UTC().Format("20060102T150405.000000000")
for _, target := range targets {
body, err := json.Marshal(map[string]any{"value": secretValuePrefix + "-" + target})
if err != nil {
t.Fatalf("marshal secret payload for %s: %v", target, err)
}
req := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/secrets/"+target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected live secret write success for %s, got %d: %s", target, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), secretValuePrefix) {
t.Fatalf("expected live secret write response to hide raw value for %s", target)
}
}
getSecretsReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/secrets", nil)
getSecretsReq.Header.Set("Authorization", "Bearer "+token)
getSecretsReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
getSecretsRec := httptest.NewRecorder()
router.ServeHTTP(getSecretsRec, getSecretsReq)
if getSecretsRec.Code != http.StatusOK {
t.Fatalf("expected live secret status fetch success, got %d: %s", getSecretsRec.Code, getSecretsRec.Body.String())
}
if strings.Contains(getSecretsRec.Body.String(), secretValuePrefix) {
t.Fatalf("expected live secret status response to hide raw values")
}
var getSecretsResp struct {
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getSecretsRec.Body.Bytes(), &getSecretsResp); err != nil {
t.Fatalf("decode live secret status response: %v", err)
}
if !getSecretsResp.TokenConfigured.Openclaw || !getSecretsResp.TokenConfigured.Vault || !getSecretsResp.TokenConfigured.Apisix {
t.Fatalf("expected all live tokenConfigured statuses true, got %#v", getSecretsResp.TokenConfigured)
}
for _, target := range targets {
req := httptest.NewRequest(http.MethodDelete, "/api/auth/xworkmate/secrets/"+target, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected live secret delete success for %s, got %d: %s", target, rec.Code, rec.Body.String())
}
}
getProfileReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getProfileReq.Header.Set("Authorization", "Bearer "+token)
getProfileReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain)
getProfileRec := httptest.NewRecorder()
router.ServeHTTP(getProfileRec, getProfileReq)
if getProfileRec.Code != http.StatusOK {
t.Fatalf("expected live profile fetch success, got %d: %s", getProfileRec.Code, getProfileRec.Body.String())
}
var profileResp struct {
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getProfileRec.Body.Bytes(), &profileResp); err != nil {
t.Fatalf("decode live profile response: %v", err)
}
if profileResp.TokenConfigured.Openclaw || profileResp.TokenConfigured.Vault || profileResp.TokenConfigured.Apisix {
t.Fatalf("expected live profile tokenConfigured statuses to reset after cleanup, got %#v", profileResp.TokenConfigured)
}
}
func TestXWorkmateVaultLiveIntegrationPrivateScope(t *testing.T) {
vaultAddr := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_ADDR"))
vaultToken := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_TOKEN"))
vaultMount := strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_MOUNT"))
if vaultAddr == "" || vaultToken == "" {
t.Skip("live vault integration requires XWORKMATE_VAULT_ADDR and XWORKMATE_VAULT_TOKEN")
}
if vaultMount == "" {
vaultMount = "kv"
}
vaultService, err := NewXWorkmateVaultService(XWorkmateVaultConfig{
Address: vaultAddr,
Token: vaultToken,
Namespace: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_NAMESPACE")),
Mount: vaultMount,
})
if err != nil {
t.Fatalf("create vault service: %v", err)
}
ctx := context.Background()
st := store.NewMemoryStore()
tenantID := "sandbox-live-" + time.Now().UTC().Format("20060102t150405000000000")
tenantDomain := tenantID + ".svc.plus"
tenant := &store.Tenant{
ID: tenantID,
Name: "Sandbox Live Tenant",
Edition: store.TenantPrivateEdition,
}
if err := st.EnsureTenant(ctx, tenant); err != nil {
t.Fatalf("ensure tenant: %v", err)
}
if err := st.EnsureTenantDomain(ctx, &store.TenantDomain{
TenantID: tenant.ID,
Domain: tenantDomain,
Kind: store.TenantDomainKindGenerated,
IsPrimary: true,
Status: store.TenantDomainStatusVerified,
}); err != nil {
t.Fatalf("ensure tenant domain: %v", err)
}
user := &store.User{
Name: "Vault Sandbox Operator",
Email: "vault-sandbox-operator@example.com",
EmailVerified: true,
Role: store.RoleAdmin,
Level: store.LevelAdmin,
Active: true,
}
if err := st.CreateUser(ctx, user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := st.UpsertTenantMembership(ctx, &store.TenantMembership{
TenantID: tenant.ID,
UserID: user.ID,
Role: store.TenantMembershipRoleAdmin,
}); err != nil {
t.Fatalf("upsert tenant membership: %v", err)
}
token := "sandbox-live-token"
if err := st.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("create session: %v", err)
}
engine := newPrivateScopeLiveRouter(t, st, vaultService)
profileBody, err := json.Marshal(map[string]any{
"profile": map[string]any{
"openclawUrl": "wss://gateway.example.com",
"openclawOrigin": "https://gateway.example.com",
"vaultUrl": vaultAddr,
"vaultNamespace": strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_NAMESPACE")),
"apisixUrl": "https://apigw.example.com",
},
})
if err != nil {
t.Fatalf("marshal profile payload: %v", err)
}
putProfileReq := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/profile", bytes.NewReader(profileBody))
putProfileReq.Header.Set("Content-Type", "application/json")
putProfileReq.Header.Set("Authorization", "Bearer "+token)
putProfileReq.Header.Set("X-Forwarded-Host", tenantDomain)
putProfileRec := httptest.NewRecorder()
engine.ServeHTTP(putProfileRec, putProfileReq)
if putProfileRec.Code != http.StatusOK {
t.Fatalf("expected private-scope profile update success, got %d: %s", putProfileRec.Code, putProfileRec.Body.String())
}
targets := []string{
store.XWorkmateSecretLocatorTargetOpenclawGatewayToken,
store.XWorkmateSecretLocatorTargetVaultRootToken,
store.XWorkmateSecretLocatorTargetAIGatewayAccessToken,
}
secretValuePrefix := "private-live-vault-check-" + time.Now().UTC().Format("20060102T150405.000000000")
for _, target := range targets {
body, err := json.Marshal(map[string]any{"value": secretValuePrefix + "-" + target})
if err != nil {
t.Fatalf("marshal secret payload for %s: %v", target, err)
}
req := httptest.NewRequest(http.MethodPut, "/api/auth/xworkmate/secrets/"+target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", tenantDomain)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected private-scope secret write success for %s, got %d: %s", target, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), secretValuePrefix) {
t.Fatalf("expected private-scope secret write response to hide raw value for %s", target)
}
}
getSecretsReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/secrets", nil)
getSecretsReq.Header.Set("Authorization", "Bearer "+token)
getSecretsReq.Header.Set("X-Forwarded-Host", tenantDomain)
getSecretsRec := httptest.NewRecorder()
engine.ServeHTTP(getSecretsRec, getSecretsReq)
if getSecretsRec.Code != http.StatusOK {
t.Fatalf("expected private-scope secret status fetch success, got %d: %s", getSecretsRec.Code, getSecretsRec.Body.String())
}
var getSecretsResp struct {
ProfileScope string `json:"profileScope"`
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getSecretsRec.Body.Bytes(), &getSecretsResp); err != nil {
t.Fatalf("decode private-scope secret status response: %v", err)
}
if getSecretsResp.ProfileScope != store.XWorkmateProfileScopeUserPrivate {
t.Fatalf("expected private profile scope, got %q", getSecretsResp.ProfileScope)
}
if !getSecretsResp.TokenConfigured.Openclaw || !getSecretsResp.TokenConfigured.Vault || !getSecretsResp.TokenConfigured.Apisix {
t.Fatalf("expected all private tokenConfigured statuses true, got %#v", getSecretsResp.TokenConfigured)
}
for _, target := range targets {
req := httptest.NewRequest(http.MethodDelete, "/api/auth/xworkmate/secrets/"+target, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-Host", tenantDomain)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected private-scope secret delete success for %s, got %d: %s", target, rec.Code, rec.Body.String())
}
}
getProfileReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile", nil)
getProfileReq.Header.Set("Authorization", "Bearer "+token)
getProfileReq.Header.Set("X-Forwarded-Host", tenantDomain)
getProfileRec := httptest.NewRecorder()
engine.ServeHTTP(getProfileRec, getProfileReq)
if getProfileRec.Code != http.StatusOK {
t.Fatalf("expected private-scope profile fetch success, got %d: %s", getProfileRec.Code, getProfileRec.Body.String())
}
var profileResp struct {
ProfileScope string `json:"profileScope"`
TokenConfigured struct {
Openclaw bool `json:"openclaw"`
Vault bool `json:"vault"`
Apisix bool `json:"apisix"`
} `json:"tokenConfigured"`
}
if err := json.Unmarshal(getProfileRec.Body.Bytes(), &profileResp); err != nil {
t.Fatalf("decode private-scope profile response: %v", err)
}
if profileResp.ProfileScope != store.XWorkmateProfileScopeUserPrivate {
t.Fatalf("expected private profile scope after cleanup, got %q", profileResp.ProfileScope)
}
if profileResp.TokenConfigured.Openclaw || profileResp.TokenConfigured.Vault || profileResp.TokenConfigured.Apisix {
t.Fatalf("expected private profile tokenConfigured statuses to reset after cleanup, got %#v", profileResp.TokenConfigured)
}
}
func newPrivateScopeLiveRouter(t *testing.T, st store.Store, vaultService xworkmateVaultService) *gin.Engine {
t.Helper()
router := gin.New()
RegisterRoutes(
router,
WithStore(st),
WithEmailVerification(false),
WithTokenService(auth.NewTokenService(auth.TokenConfig{
PublicToken: "public-token",
RefreshSecret: "refresh-secret",
AccessSecret: "access-secret",
AccessExpiry: time.Hour,
RefreshExpiry: time.Hour,
Store: st,
})),
WithXWorkmateVaultService(vaultService),
)
return router
}

View File

@ -907,6 +907,16 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
}()
}
xworkmateVaultService, err := api.NewXWorkmateVaultService(api.XWorkmateVaultConfig{
Address: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_ADDR")),
Token: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_TOKEN")),
Namespace: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_NAMESPACE")),
Mount: strings.TrimSpace(os.Getenv("XWORKMATE_VAULT_MOUNT")),
})
if err != nil {
return err
}
options := []api.Option{
api.WithStore(st),
api.WithSessionTTL(cfg.Session.TTL),
@ -922,6 +932,9 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err
FrontendURL: strings.TrimSpace(cfg.Auth.OAuth.FrontendURL),
}),
}
if xworkmateVaultService != nil {
options = append(options, api.WithXWorkmateVaultService(xworkmateVaultService))
}
if agentRegistry != nil {
options = append(options, api.WithAgentStatusReader(agentRegistry))

View File

@ -11,6 +11,14 @@
- `Authorization: Bearer <session-token>`
- Cookie `xc_session=<session-token>`
## XWorkmate Vault 集成
- `GET /api/auth/xworkmate/profile` 继续只返回非敏感配置、locator 元数据和 `tokenConfigured`
- `PUT /api/auth/xworkmate/profile` 禁止持久化任何 raw token/password/api key 字段
- `GET /api/auth/xworkmate/secrets` 只返回 target / locator / configured|missing 状态
- `PUT /api/auth/xworkmate/secrets/:target``DELETE /api/auth/xworkmate/secrets/:target` 走服务端 Vault backend
- 所有 XWorkmate secret API 都不会返回 raw secret
## 邮件验证
- 发送验证码:`POST /api/auth/register/send`

View File

@ -17,6 +17,11 @@
- `GET /api/auth/session`:获取当前会话用户
- `DELETE /api/auth/session`:注销
- `GET /api/auth/xworkmate/profile`:获取 XWorkmate 非敏感 profile / locator / tokenConfigured
- `PUT /api/auth/xworkmate/profile`:更新 XWorkmate 非敏感 profile / locator
- `GET /api/auth/xworkmate/secrets`:获取 XWorkmate Vault-backed secret 状态(不返回原文)
- `PUT /api/auth/xworkmate/secrets/:target`:写入指定 XWorkmate secret 到 Vault不返回原文
- `DELETE /api/auth/xworkmate/secrets/:target`:删除指定 XWorkmate secret同时保留 locator 元数据
- `POST /api/auth/mfa/totp/provision`:申请 MFA TOTP secret
- `POST /api/auth/mfa/totp/verify`:验证 MFA TOTP
- `POST /api/auth/mfa/disable`:关闭 MFA

View File

@ -33,6 +33,7 @@ const (
XWorkmateSecretLocatorProviderVault = "vault"
XWorkmateSecretLocatorTargetOpenclawGatewayToken = "openclaw.gateway_token"
XWorkmateSecretLocatorTargetVaultRootToken = "vault.root_token"
XWorkmateSecretLocatorTargetAIGatewayAccessToken = "ai_gateway.access_token"
XWorkmateSecretLocatorTargetOllamaCloudAPIKey = "ollama_cloud.api_key"