diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 4d0c725..17bc249 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -270,3 +270,9 @@ jobs: - name: Validate Deployed Accounts Service run: bash ./scripts/github-actions/validate-deploy.sh "${{ needs.build.outputs.service_image_ref }}" https://accounts.svc.plus + + - name: Validate Review XWorkmate Sync Contract + env: + REVIEW_ACCOUNT_EMAIL: review@svc.plus + REVIEW_ACCOUNT_PASSWORD: ${{ secrets.REVIEW_ACCOUNT_PASSWORD }} + run: bash ./scripts/github-actions/validate-review-xworkmate-sync.sh https://accounts.svc.plus diff --git a/.gitignore b/.gitignore index 6da7478..e5c1369 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ ansible/vars/*.host.yml ansible/vars/*.vault.yml init.json +.worktrees/ diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index 82d3bc6..329cb75 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -46,6 +46,9 @@ const ( SandboxEmail = "sandbox@svc.plus" // ReviewEmail is the canonical email for the readonly App Review account. ReviewEmail = "review@svc.plus" + // SharedXWorkmateBridgeServerURL is the managed bridge endpoint exposed to + // tenant-shared xworkmate clients such as the App Review account. + SharedXWorkmateBridgeServerURL = "https://xworkmate-bridge.svc.plus" ) const ( @@ -61,6 +64,147 @@ var defaultReviewPermissions = []string{ "admin.blacklist.read", } +type sharedXWorkmateBootstrapConfig struct { + BridgeServerURL string + BridgeAuthToken string +} + +func resolveSharedXWorkmateBootstrapConfig() sharedXWorkmateBootstrapConfig { + return sharedXWorkmateBootstrapConfig{ + BridgeServerURL: firstNonEmptyString( + os.Getenv("XWORKMATE_BRIDGE_SERVER_URL"), + os.Getenv("BRIDGE_SERVER_URL"), + SharedXWorkmateBridgeServerURL, + ), + BridgeAuthToken: firstNonEmptyString( + os.Getenv("BRIDGE_AUTH_TOKEN"), + os.Getenv("INTERNAL_SERVICE_TOKEN"), + ), + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func normalizeBridgeServerOrigin(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String() +} + +func managedSharedXWorkmateBridgeLocator() store.XWorkmateSecretLocator { + locator := store.XWorkmateSecretLocator{ + ID: strings.Join([]string{"managed", store.SharedXWorkmateTenantID, "", store.XWorkmateProfileScopeTenantShared, store.XWorkmateSecretLocatorTargetBridgeAuthToken}, "|"), + Provider: store.XWorkmateSecretLocatorProviderVault, + SecretPath: fmt.Sprintf("xworkmate/tenants/%s/shared", store.SharedXWorkmateTenantID), + SecretKey: store.XWorkmateSecretLocatorTargetBridgeAuthToken, + Target: store.XWorkmateSecretLocatorTargetBridgeAuthToken, + Required: true, + } + store.NormalizeXWorkmateSecretLocator(&locator) + return locator +} + +func upsertXWorkmateSecretLocator( + locators []store.XWorkmateSecretLocator, + locator store.XWorkmateSecretLocator, +) []store.XWorkmateSecretLocator { + next := make([]store.XWorkmateSecretLocator, 0, len(locators)+1) + replaced := false + for _, current := range locators { + store.NormalizeXWorkmateSecretLocator(¤t) + if current.Target == locator.Target { + next = append(next, locator) + replaced = true + continue + } + next = append(next, current) + } + if !replaced { + next = append(next, locator) + } + return next +} + +func ensureSharedReviewXWorkmateProfile( + ctx context.Context, + st store.Store, + reviewCfg config.ReviewAccount, + bootstrap sharedXWorkmateBootstrapConfig, + writeSecret func(context.Context, store.XWorkmateSecretLocator, string) error, + logger *slog.Logger, +) error { + if !reviewCfg.Enabled { + return nil + } + + bridgeServerURL := strings.TrimSpace(bootstrap.BridgeServerURL) + if bridgeServerURL == "" { + return fmt.Errorf("shared xworkmate bridge server url is required") + } + parsedBridgeURL, err := url.Parse(bridgeServerURL) + if err != nil || parsedBridgeURL.Scheme == "" || parsedBridgeURL.Host == "" { + return fmt.Errorf("shared xworkmate bridge server url is invalid: %q", bridgeServerURL) + } + bridgeAuthToken := strings.TrimSpace(bootstrap.BridgeAuthToken) + if bridgeAuthToken == "" { + return fmt.Errorf("shared xworkmate bridge auth token is required") + } + if writeSecret == nil { + return fmt.Errorf("xworkmate vault service is required for shared bridge bootstrap") + } + + profile, err := st.GetXWorkmateProfile( + ctx, + store.SharedXWorkmateTenantID, + "", + store.XWorkmateProfileScopeTenantShared, + ) + if err != nil && !errors.Is(err, store.ErrXWorkmateProfileNotFound) { + return fmt.Errorf("load shared xworkmate profile: %w", err) + } + if errors.Is(err, store.ErrXWorkmateProfileNotFound) || profile == nil { + profile = &store.XWorkmateProfile{ + TenantID: store.SharedXWorkmateTenantID, + Scope: store.XWorkmateProfileScopeTenantShared, + } + } + + locator := managedSharedXWorkmateBridgeLocator() + profile.BridgeServerURL = bridgeServerURL + profile.BridgeServerOrigin = normalizeBridgeServerOrigin(bridgeServerURL) + profile.SecretLocators = upsertXWorkmateSecretLocator( + profile.SecretLocators, + locator, + ) + + if err := st.UpsertXWorkmateProfile(ctx, profile); err != nil { + return fmt.Errorf("upsert shared xworkmate profile: %w", err) + } + if err := writeSecret(ctx, locator, bridgeAuthToken); err != nil { + return fmt.Errorf("persist shared bridge auth token: %w", err) + } + if logger != nil { + logger.Info( + "shared xworkmate bridge contract ensured", + "bridgeServerURL", + bridgeServerURL, + "profileScope", + store.XWorkmateProfileScopeTenantShared, + ) + } + return nil +} + func ensureReviewUser(ctx context.Context, st store.Store, cfg config.ReviewAccount, logger *slog.Logger) error { email := strings.ToLower(strings.TrimSpace(cfg.Email)) if email == "" { @@ -916,6 +1060,25 @@ func runServer(ctx context.Context, cfg *config.Config, logger *slog.Logger) err if err != nil { return err } + if err := ensureSharedReviewXWorkmateProfile( + ctx, + st, + cfg.ReviewAccount, + resolveSharedXWorkmateBootstrapConfig(), + func( + ctx context.Context, + locator store.XWorkmateSecretLocator, + value string, + ) error { + if xworkmateVaultService == nil { + return errors.New("xworkmate vault service is unavailable") + } + return xworkmateVaultService.WriteSecret(ctx, locator, value) + }, + logger, + ); err != nil { + logger.Warn("failed to ensure shared review xworkmate profile", "err", err) + } options := []api.Option{ api.WithStore(st), diff --git a/cmd/accountsvc/main_test.go b/cmd/accountsvc/main_test.go new file mode 100644 index 0000000..f81fdf8 --- /dev/null +++ b/cmd/accountsvc/main_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "log/slog" + "testing" + + "account/config" + "account/internal/store" +) + +func TestEnsureSharedReviewXWorkmateProfileBootstrapsManagedBridgeContract(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMemoryStore() + if err := st.EnsureTenant(ctx, &store.Tenant{ + ID: store.SharedXWorkmateTenantID, + Name: store.SharedXWorkmateTenantName, + Edition: store.SharedPublicTenantEdition, + }); err != nil { + t.Fatalf("ensure tenant: %v", err) + } + + writes := make([]struct { + locator store.XWorkmateSecretLocator + value string + }, 0, 1) + err := ensureSharedReviewXWorkmateProfile( + ctx, + st, + config.ReviewAccount{Enabled: true}, + sharedXWorkmateBootstrapConfig{ + BridgeServerURL: SharedXWorkmateBridgeServerURL, + BridgeAuthToken: "bridge-token", + }, + func( + ctx context.Context, + locator store.XWorkmateSecretLocator, + value string, + ) error { + writes = append(writes, struct { + locator store.XWorkmateSecretLocator + value string + }{locator: locator, value: value}) + return nil + }, + slog.Default(), + ) + if err != nil { + t.Fatalf("ensure shared review xworkmate profile: %v", err) + } + + profile, err := st.GetXWorkmateProfile( + ctx, + store.SharedXWorkmateTenantID, + "", + store.XWorkmateProfileScopeTenantShared, + ) + if err != nil { + t.Fatalf("load shared profile: %v", err) + } + + if got := profile.BridgeServerURL; got != SharedXWorkmateBridgeServerURL { + t.Fatalf("expected bridge server url %q, got %q", SharedXWorkmateBridgeServerURL, got) + } + if got := profile.BridgeServerOrigin; got != SharedXWorkmateBridgeServerURL { + t.Fatalf("expected bridge server origin %q, got %q", SharedXWorkmateBridgeServerURL, got) + } + if len(profile.SecretLocators) != 1 { + t.Fatalf("expected 1 secret locator, got %d", len(profile.SecretLocators)) + } + locator := profile.SecretLocators[0] + if locator.Target != store.XWorkmateSecretLocatorTargetBridgeAuthToken { + t.Fatalf("expected bridge auth token locator, got %#v", locator) + } + if locator.SecretPath != "xworkmate/tenants/svc-plus-xworkmate/shared" { + t.Fatalf("expected managed shared secret path, got %#v", locator) + } + if len(writes) != 1 { + t.Fatalf("expected 1 secret write, got %d", len(writes)) + } + if writes[0].value != "bridge-token" { + t.Fatalf("expected secret value bridge-token, got %q", writes[0].value) + } +} + +func TestEnsureSharedReviewXWorkmateProfileRequiresBridgeContract(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMemoryStore() + if err := st.EnsureTenant(ctx, &store.Tenant{ + ID: store.SharedXWorkmateTenantID, + Name: store.SharedXWorkmateTenantName, + Edition: store.SharedPublicTenantEdition, + }); err != nil { + t.Fatalf("ensure tenant: %v", err) + } + + err := ensureSharedReviewXWorkmateProfile( + ctx, + st, + config.ReviewAccount{Enabled: true}, + sharedXWorkmateBootstrapConfig{ + BridgeServerURL: SharedXWorkmateBridgeServerURL, + }, + func(context.Context, store.XWorkmateSecretLocator, string) error { + return nil + }, + nil, + ) + if err == nil || err.Error() != "shared xworkmate bridge auth token is required" { + t.Fatalf("expected missing bridge token error, got %v", err) + } +} diff --git a/scripts/github-actions/validate-review-xworkmate-sync.sh b/scripts/github-actions/validate-review-xworkmate-sync.sh new file mode 100644 index 0000000..c61aedf --- /dev/null +++ b/scripts/github-actions/validate-review-xworkmate-sync.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-https://accounts.svc.plus}" +REVIEW_ACCOUNT_EMAIL="${REVIEW_ACCOUNT_EMAIL:-review@svc.plus}" +REVIEW_ACCOUNT_PASSWORD="${REVIEW_ACCOUNT_PASSWORD:-Review123!}" + +login_json="$( + curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --max-time 20 \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json' \ + --data "{\"identifier\":\"${REVIEW_ACCOUNT_EMAIL}\",\"password\":\"${REVIEW_ACCOUNT_PASSWORD}\"}" \ + "${BASE_URL}/api/auth/login" +)" + +session_token="$( + LOGIN_JSON="${login_json}" python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["LOGIN_JSON"]) +token = (payload.get("token") or payload.get("access_token") or "").strip() +if not token: + raise SystemExit("review account login did not return a session token") +print(token) +PY +)" + +sync_json="$( + curl \ + --silent \ + --show-error \ + --fail \ + --location \ + --max-time 20 \ + --header 'Accept: application/json' \ + --header "Authorization: Bearer ${session_token}" \ + "${BASE_URL}/api/auth/xworkmate/profile/sync" +)" + +SYNC_JSON="${sync_json}" python3 - <<'PY' +import json +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() + +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") +PY