diff --git a/api/api.go b/api/api.go index 5c4204b..0c5aa39 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/api/xworkmate.go b/api/xworkmate.go index b2dc85e..2c259d3 100644 --- a/api/xworkmate.go +++ b/api/xworkmate.go @@ -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) { diff --git a/api/xworkmate_test.go b/api/xworkmate_test.go index 3adbec9..c4669ca 100644 --- a/api/xworkmate_test.go +++ b/api/xworkmate_test.go @@ -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) + } +} diff --git a/api/xworkmate_vault.go b/api/xworkmate_vault.go new file mode 100644 index 0000000..19778d7 --- /dev/null +++ b/api/xworkmate_vault.go @@ -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 +} diff --git a/api/xworkmate_vault_live_test.go b/api/xworkmate_vault_live_test.go new file mode 100644 index 0000000..1e5a0be --- /dev/null +++ b/api/xworkmate_vault_live_test.go @@ -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 +} diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index fc16655..82d3bc6 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -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)) diff --git a/docs/api/auth.md b/docs/api/auth.md index 3d81ef2..579ea8f 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -11,6 +11,14 @@ - `Authorization: Bearer ` 或 - Cookie `xc_session=` +## 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` diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 8cb410c..1947285 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -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 diff --git a/internal/store/xworkmate.go b/internal/store/xworkmate.go index c8e789a..03db152 100644 --- a/internal/store/xworkmate.go +++ b/internal/store/xworkmate.go @@ -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"