diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 17bc249..17f1cef 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -237,6 +237,9 @@ jobs: env: ACCOUNTS_IMAGE_REF: ${{ steps.deploy_image.outputs.image_ref }} ACCOUNTS_PULL_IMAGE: "true" + BRIDGE_AUTH_TOKEN: ${{ secrets.BRIDGE_AUTH_TOKEN }} + BRIDGE_REVIEW_AUTH_TOKEN: ${{ secrets.BRIDGE_REVIEW_AUTH_TOKEN }} + BRIDGE_SERVER_URL: https://xworkmate-bridge.svc.plus run: | set -euo pipefail @@ -275,4 +278,6 @@ jobs: env: REVIEW_ACCOUNT_EMAIL: review@svc.plus REVIEW_ACCOUNT_PASSWORD: ${{ secrets.REVIEW_ACCOUNT_PASSWORD }} + BRIDGE_AUTH_TOKEN: ${{ secrets.BRIDGE_AUTH_TOKEN }} + BRIDGE_REVIEW_AUTH_TOKEN: ${{ secrets.BRIDGE_REVIEW_AUTH_TOKEN }} run: bash ./scripts/github-actions/validate-review-xworkmate-sync.sh https://accounts.svc.plus diff --git a/api/xworkmate.go b/api/xworkmate.go index da3d0d2..963bf3c 100644 --- a/api/xworkmate.go +++ b/api/xworkmate.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strings" "github.com/gin-gonic/gin" @@ -668,6 +669,14 @@ func (h *handler) getXWorkmateProfileSync(c *gin.Context) { respondError(c, http.StatusConflict, "bridge_auth_token_unavailable", "bridge auth token is unavailable") return } + if isReviewXWorkmateAccount(user) { + reviewToken := strings.TrimSpace(os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN")) + if reviewToken == "" { + respondError(c, http.StatusConflict, "bridge_review_auth_token_unavailable", "bridge review auth token is unavailable") + return + } + bridgeAuthToken = reviewToken + } c.JSON(http.StatusOK, gin.H{ "BRIDGE_SERVER_URL": bridgeServerURL, @@ -675,6 +684,13 @@ func (h *handler) getXWorkmateProfileSync(c *gin.Context) { }) } +func isReviewXWorkmateAccount(user *store.User) bool { + if user == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(user.Email), "review@svc.plus") +} + func (h *handler) updateXWorkmateProfile(c *gin.Context) { user, ok := h.currentAuthenticatedUser(c) if !ok { diff --git a/api/xworkmate_test.go b/api/xworkmate_test.go index e1c05ff..ce36436 100644 --- a/api/xworkmate_test.go +++ b/api/xworkmate_test.go @@ -221,6 +221,147 @@ func TestGetXWorkmateProfileSyncReturnsManagedBridgeCredentials(t *testing.T) { } } +func TestGetXWorkmateProfileSyncReturnsReviewBridgeTokenForReviewAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("BRIDGE_REVIEW_AUTH_TOKEN", "review-token-value") + + vaultService := newMemoryXWorkmateVaultService() + router, _, token := newXWorkmateTestHarnessWithVault(t, &store.User{ + Name: "Apple Review", + Email: "review@svc.plus", + EmailVerified: true, + Role: store.RoleAdmin, + Level: store.LevelAdmin, + Active: true, + }, vaultService) + + profileBody, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "BRIDGE_SERVER_URL": "https://xworkmate-bridge.svc.plus", + "secretLocators": []map[string]any{ + { + "id": "locator-openclaw", + "provider": "vault", + "secretPath": "kv/openclaw", + "secretKey": "token", + "target": store.XWorkmateSecretLocatorTargetBridgeAuthToken, + "required": true, + }, + }, + }, + }) + if err != nil { + t.Fatalf("marshal profile: %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()) + } + + if err := vaultService.WriteSecret(context.Background(), store.XWorkmateSecretLocator{ + Provider: "vault", + SecretPath: "kv/openclaw", + SecretKey: "token", + Target: store.XWorkmateSecretLocatorTargetBridgeAuthToken, + }, "production-token-value"); err != nil { + t.Fatalf("write secret: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", 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 profile sync success, got %d: %s", rec.Code, rec.Body.String()) + } + + var payload struct { + BridgeServerURL string `json:"BRIDGE_SERVER_URL"` + BridgeAuthToken string `json:"BRIDGE_AUTH_TOKEN"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode profile sync response: %v", err) + } + if payload.BridgeServerURL != "https://xworkmate-bridge.svc.plus" { + t.Fatalf("expected bridge server url, got %#v", payload) + } + if payload.BridgeAuthToken != "review-token-value" { + t.Fatalf("expected review bridge auth token, got %#v", payload) + } + if payload.BridgeAuthToken == "production-token-value" { + t.Fatalf("review account must not receive production bridge token") + } +} + +func TestGetXWorkmateProfileSyncRejectsReviewAccountWithoutReviewBridgeToken(t *testing.T) { + gin.SetMode(gin.TestMode) + + vaultService := newMemoryXWorkmateVaultService() + router, _, token := newXWorkmateTestHarnessWithVault(t, &store.User{ + Name: "Apple Review", + Email: "review@svc.plus", + EmailVerified: true, + Role: store.RoleAdmin, + Level: store.LevelAdmin, + Active: true, + }, vaultService) + + profileBody, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "BRIDGE_SERVER_URL": "https://xworkmate-bridge.svc.plus", + "secretLocators": []map[string]any{ + { + "id": "locator-openclaw", + "provider": "vault", + "secretPath": "kv/openclaw", + "secretKey": "token", + "target": store.XWorkmateSecretLocatorTargetBridgeAuthToken, + "required": true, + }, + }, + }, + }) + if err != nil { + t.Fatalf("marshal profile: %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()) + } + + if err := vaultService.WriteSecret(context.Background(), store.XWorkmateSecretLocator{ + Provider: "vault", + SecretPath: "kv/openclaw", + SecretKey: "token", + Target: store.XWorkmateSecretLocatorTargetBridgeAuthToken, + }, "production-token-value"); err != nil { + t.Fatalf("write secret: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/profile/sync", 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.StatusConflict { + t.Fatalf("expected profile sync conflict, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "bridge_review_auth_token_unavailable") { + t.Fatalf("expected review token error, got %s", rec.Body.String()) + } +} + func TestGetXWorkmateProfileSyncConflictsWhenManagedBridgeContractMissing(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/deploy/gcp/cloud-run/preview-service.yaml b/deploy/gcp/cloud-run/preview-service.yaml index ad88fa9..0365039 100644 --- a/deploy/gcp/cloud-run/preview-service.yaml +++ b/deploy/gcp/cloud-run/preview-service.yaml @@ -56,6 +56,18 @@ spec: secretKeyRef: name: internal-service-token key: latest + - name: BRIDGE_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: bridge-auth-token + key: latest + - name: BRIDGE_REVIEW_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: bridge-review-auth-token + key: latest + - name: BRIDGE_SERVER_URL + value: "https://xworkmate-bridge.svc.plus" # --- SMTP Configuration --- - name: SMTP_HOST value: "smtp.qq.com" diff --git a/deploy/gcp/cloud-run/prod-service.yaml b/deploy/gcp/cloud-run/prod-service.yaml index 2445871..6bb9d87 100644 --- a/deploy/gcp/cloud-run/prod-service.yaml +++ b/deploy/gcp/cloud-run/prod-service.yaml @@ -56,6 +56,18 @@ spec: secretKeyRef: name: internal-service-token key: latest + - name: BRIDGE_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: bridge-auth-token + key: latest + - name: BRIDGE_REVIEW_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: bridge-review-auth-token + key: latest + - name: BRIDGE_SERVER_URL + value: "https://xworkmate-bridge.svc.plus" # --- SMTP Configuration --- - name: SMTP_HOST value: "smtp.qq.com" diff --git a/scripts/github-actions/validate-review-xworkmate-sync.sh b/scripts/github-actions/validate-review-xworkmate-sync.sh index c61aedf..314c1b3 100644 --- a/scripts/github-actions/validate-review-xworkmate-sync.sh +++ b/scripts/github-actions/validate-review-xworkmate-sync.sh @@ -50,10 +50,18 @@ import os payload = json.loads(os.environ["SYNC_JSON"]) bridge_server_url = (payload.get("BRIDGE_SERVER_URL") or "").strip() bridge_auth_token = (payload.get("BRIDGE_AUTH_TOKEN") or "").strip() +expected_review_token = os.environ.get("BRIDGE_REVIEW_AUTH_TOKEN", "").strip() +production_token = os.environ.get("BRIDGE_AUTH_TOKEN", "").strip() if not bridge_server_url: raise SystemExit("review xworkmate sync did not return BRIDGE_SERVER_URL") if not bridge_auth_token: raise SystemExit("review xworkmate sync did not return BRIDGE_AUTH_TOKEN") + +if expected_review_token and bridge_auth_token != expected_review_token: + raise SystemExit("review xworkmate sync did not return the review bridge token") + +if production_token and bridge_auth_token == production_token: + raise SystemExit("review xworkmate sync returned the production bridge token") PY