feat(accounts): add vault-backed xworkmate secrets
This commit is contained in:
parent
e9fb4af72b
commit
c98688cd51
12
api/api.go
12
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)
|
||||
|
||||
433
api/xworkmate.go
433
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) {
|
||||
|
||||
@ -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
351
api/xworkmate_vault.go
Normal 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
|
||||
}
|
||||
357
api/xworkmate_vault_live_test.go
Normal file
357
api/xworkmate_vault_live_test.go
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user