diff --git a/api/api.go b/api/api.go index 4d0598b..e20a835 100644 --- a/api/api.go +++ b/api/api.go @@ -39,6 +39,8 @@ const defaultPasswordResetTTL = 30 * time.Minute const maxMFAVerificationAttempts = 5 const defaultMFALockoutDuration = 5 * time.Minute const defaultOAuthExchangeCodeTTL = 5 * time.Minute +const defaultBridgeBootstrapTTL = 5 * time.Minute +const defaultBridgeBootstrapTarget = "https://xworkmate-bridge.svc.plus" const sessionCookieName = "xc_session" @@ -74,6 +76,10 @@ type handler struct { oauthExchangeCodes map[string]oauthExchangeCode oauthExchangeMu sync.RWMutex oauthExchangeTTL time.Duration + bridgeBootstrapTickets map[string]bridgeBootstrapTicket + bridgeBootstrapByCode map[string]string + bridgeBootstrapMu sync.RWMutex + bridgeBootstrapTTL time.Duration metricsProvider service.UserMetricsProvider agentStatusReader agentStatusReader tokenService *auth.TokenService @@ -303,6 +309,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { passwordResets: make(map[string]passwordReset), oauthExchangeCodes: make(map[string]oauthExchangeCode), oauthExchangeTTL: defaultOAuthExchangeCodeTTL, + bridgeBootstrapTickets: make(map[string]bridgeBootstrapTicket), + bridgeBootstrapByCode: make(map[string]string), + bridgeBootstrapTTL: defaultBridgeBootstrapTTL, } for _, opt := range opts { @@ -361,6 +370,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets) authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret) authProtected.DELETE("/xworkmate/secrets/:target", h.deleteXWorkmateSecret) + authProtected.POST("/xworkmate/bridge/bootstrap", h.createXWorkmateBridgeBootstrapTicket) + authProtected.GET("/xworkmate/bridge/bootstrap/:shortCode", h.lookupXWorkmateBridgeBootstrapTicket) + authProtected.POST("/xworkmate/bridge/bootstrap/:ticketId/revoke", h.revokeXWorkmateBridgeBootstrapTicket) authProtected.POST("/mfa/totp/provision", h.provisionTOTP) authProtected.POST("/mfa/totp/verify", h.verifyTOTP) @@ -417,6 +429,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) { internalGroup.GET("/network/identities", h.internalNetworkIdentities) internalGroup.GET("/policy/:accountUUID", h.internalAccountPolicy) internalGroup.POST("/nodes/heartbeat", h.internalNodeHeartbeat) + internalGroup.POST("/xworkmate/bridge/bootstrap/consume", h.internalConsumeXWorkmateBridgeBootstrapTicket) // Public /api routes for admin/management (expected by frontend at /api/admin/...) apiGroup := r.Group("/api") diff --git a/api/xworkmate_bridge_bootstrap.go b/api/xworkmate_bridge_bootstrap.go new file mode 100644 index 0000000..6adb4e2 --- /dev/null +++ b/api/xworkmate_bridge_bootstrap.go @@ -0,0 +1,319 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "account/internal/auth" + "account/internal/store" +) + +const ( + bridgeBootstrapScheme = "xworkmate-bridge-bootstrap" + bridgeBootstrapScopeA = "connect" + bridgeBootstrapScopeB = "pairing.bootstrap" + bridgeBootstrapShortLen = 8 +) + +type bridgeBootstrapTicket struct { + TicketID string + ShortCode string + TenantID string + UserID string + ProfileScope string + TargetBridge string + Scopes []string + ExpiresAt time.Time + OneTime bool + IssuedAt time.Time + ConsumedAt time.Time + RevokedAt time.Time +} + +type bridgeBootstrapIssueResponse struct { + Ticket string `json:"ticket"` + ShortCode string `json:"shortCode"` + Bridge string `json:"bridge"` + Scheme string `json:"scheme"` + ExpiresAt string `json:"expiresAt"` + Scopes []string `json:"scopes"` + OneTime bool `json:"oneTime"` + QRPayload string `json:"qrPayload"` +} + +type bridgeBootstrapConsumeResponse struct { + TicketID string `json:"ticketId"` + TargetBridge string `json:"targetBridge"` + OpenclawURL string `json:"openclawUrl"` + AuthMode string `json:"authMode"` + ExchangeToken string `json:"exchangeToken"` + ExpiresAt string `json:"expiresAt"` + Scopes []string `json:"scopes"` +} + +func sanitizeBridgeTarget(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return defaultBridgeBootstrapTarget + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return defaultBridgeBootstrapTarget + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) +} + +func newBridgeBootstrapQRCode(ticketID, bridge string) string { + payload, _ := json.Marshal(gin.H{ + "scheme": bridgeBootstrapScheme, + "ticket": ticketID, + "bridge": bridge, + }) + return string(payload) +} + +func newBridgeBootstrapShortCode() string { + for { + raw := make([]byte, 6) + _, _ = rand.Read(raw) + code := strings.ToUpper( + strings.TrimRight(base64.RawURLEncoding.EncodeToString(raw), "="), + ) + code = strings.NewReplacer("-", "A", "_", "B").Replace(code) + if len(code) >= bridgeBootstrapShortLen { + return code[:bridgeBootstrapShortLen] + } + } +} + +func (h *handler) storeBridgeBootstrapTicket(ticket bridgeBootstrapTicket) { + h.bridgeBootstrapMu.Lock() + defer h.bridgeBootstrapMu.Unlock() + h.bridgeBootstrapTickets[ticket.TicketID] = ticket + h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID +} + +func (h *handler) loadBridgeBootstrapTicketByID(ticketID string) (bridgeBootstrapTicket, bool) { + h.bridgeBootstrapMu.RLock() + defer h.bridgeBootstrapMu.RUnlock() + ticket, ok := h.bridgeBootstrapTickets[strings.TrimSpace(ticketID)] + return ticket, ok +} + +func (h *handler) loadBridgeBootstrapTicketByCode(shortCode string) (bridgeBootstrapTicket, bool) { + h.bridgeBootstrapMu.RLock() + defer h.bridgeBootstrapMu.RUnlock() + ticketID, ok := h.bridgeBootstrapByCode[strings.ToUpper(strings.TrimSpace(shortCode))] + if !ok { + return bridgeBootstrapTicket{}, false + } + ticket, ok := h.bridgeBootstrapTickets[ticketID] + return ticket, ok +} + +func (h *handler) updateBridgeBootstrapTicket(ticket bridgeBootstrapTicket) { + h.bridgeBootstrapMu.Lock() + defer h.bridgeBootstrapMu.Unlock() + h.bridgeBootstrapTickets[ticket.TicketID] = ticket + if ticket.ShortCode != "" { + h.bridgeBootstrapByCode[strings.ToUpper(ticket.ShortCode)] = ticket.TicketID + } +} + +func bridgeBootstrapExpired(ticket bridgeBootstrapTicket, now time.Time) bool { + return ticket.ExpiresAt.IsZero() || now.After(ticket.ExpiresAt) +} + +func requireBridgeBootstrapAccess(c *gin.Context, h *handler) (*store.User, *xworkmateAccessContext, bool) { + user, ok := h.currentAuthenticatedUser(c) + if !ok { + return nil, nil, false + } + access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user) + if err != nil { + respondError(c, http.StatusForbidden, "tenant_access_denied", "failed to resolve tenant access") + return nil, nil, false + } + return user, access, true +} + +func (h *handler) createXWorkmateBridgeBootstrapTicket(c *gin.Context) { + user, access, ok := requireBridgeBootstrapAccess(c, h) + if !ok { + return + } + if !auth.IsMFAVerified(c) { + respondError(c, http.StatusForbidden, "mfa_required", "mfa verification required") + return + } + + var req struct { + TargetBridge string `json:"targetBridge"` + } + _ = c.ShouldBindJSON(&req) + + now := time.Now().UTC() + ticket := bridgeBootstrapTicket{ + TicketID: generateRandomState(), + ShortCode: newBridgeBootstrapShortCode(), + TenantID: strings.TrimSpace(access.Tenant.ID), + UserID: strings.TrimSpace(user.ID), + ProfileScope: access.ProfileScope, + TargetBridge: sanitizeBridgeTarget(req.TargetBridge), + Scopes: []string{bridgeBootstrapScopeA, bridgeBootstrapScopeB}, + ExpiresAt: now.Add(h.bridgeBootstrapTTL), + OneTime: true, + IssuedAt: now, + } + h.storeBridgeBootstrapTicket(ticket) + + c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{ + Ticket: ticket.TicketID, + ShortCode: ticket.ShortCode, + Bridge: ticket.TargetBridge, + Scheme: bridgeBootstrapScheme, + ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), + Scopes: append([]string(nil), ticket.Scopes...), + OneTime: ticket.OneTime, + QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge), + }) +} + +func (h *handler) lookupXWorkmateBridgeBootstrapTicket(c *gin.Context) { + user, access, ok := requireBridgeBootstrapAccess(c, h) + if !ok { + return + } + ticket, found := h.loadBridgeBootstrapTicketByCode(c.Param("shortCode")) + if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) { + respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") + return + } + if bridgeBootstrapExpired(ticket, time.Now().UTC()) { + respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired") + return + } + if !ticket.RevokedAt.IsZero() { + respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked") + return + } + c.JSON(http.StatusOK, bridgeBootstrapIssueResponse{ + Ticket: ticket.TicketID, + ShortCode: ticket.ShortCode, + Bridge: ticket.TargetBridge, + Scheme: bridgeBootstrapScheme, + ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), + Scopes: append([]string(nil), ticket.Scopes...), + OneTime: ticket.OneTime, + QRPayload: newBridgeBootstrapQRCode(ticket.TicketID, ticket.TargetBridge), + }) +} + +func (h *handler) revokeXWorkmateBridgeBootstrapTicket(c *gin.Context) { + user, access, ok := requireBridgeBootstrapAccess(c, h) + if !ok { + return + } + ticket, found := h.loadBridgeBootstrapTicketByID(c.Param("ticketId")) + if !found || ticket.UserID != strings.TrimSpace(user.ID) || ticket.TenantID != strings.TrimSpace(access.Tenant.ID) { + respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") + return + } + ticket.RevokedAt = time.Now().UTC() + h.updateBridgeBootstrapTicket(ticket) + c.JSON(http.StatusOK, gin.H{"ok": true, "ticketId": ticket.TicketID, "revokedAt": ticket.RevokedAt.Format(time.RFC3339)}) +} + +func (h *handler) internalConsumeXWorkmateBridgeBootstrapTicket(c *gin.Context) { + if !h.ensureXWorkmateVaultService(c) { + return + } + var req struct { + Ticket string `json:"ticket"` + Bridge string `json:"bridge"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "invalid_request", "invalid bridge bootstrap consume request") + return + } + ticket, found := h.loadBridgeBootstrapTicketByID(req.Ticket) + if !found { + respondError(c, http.StatusNotFound, "bridge_bootstrap_not_found", "bridge bootstrap ticket not found") + return + } + now := time.Now().UTC() + if bridgeBootstrapExpired(ticket, now) { + respondError(c, http.StatusGone, "bridge_bootstrap_expired", "bridge bootstrap ticket expired") + return + } + if !ticket.RevokedAt.IsZero() { + respondError(c, http.StatusGone, "bridge_bootstrap_revoked", "bridge bootstrap ticket revoked") + return + } + if ticket.OneTime && !ticket.ConsumedAt.IsZero() { + respondError(c, http.StatusConflict, "bridge_bootstrap_consumed", "bridge bootstrap ticket already consumed") + return + } + if sanitizeBridgeTarget(req.Bridge) != ticket.TargetBridge { + respondError(c, http.StatusForbidden, "bridge_bootstrap_target_mismatch", "bridge bootstrap target mismatch") + return + } + + ctx := c.Request.Context() + profile, err := h.loadXWorkmateProfile( + ctx, + &xworkmateAccessContext{ + ProfileScope: ticket.ProfileScope, + Tenant: &store.Tenant{ + ID: ticket.TenantID, + }, + }, + &store.User{ + ID: ticket.UserID, + }, + ) + if err != nil { + respondError(c, http.StatusInternalServerError, "xworkmate_profile_unavailable", "failed to load xworkmate profile") + return + } + if profile == nil { + respondError(c, http.StatusNotFound, "xworkmate_profile_not_found", "xworkmate profile not found") + return + } + locator, ok := findStoredXWorkmateSecretLocator(profile, store.XWorkmateSecretLocatorTargetOpenclawGatewayToken) + if !ok { + respondError(c, http.StatusConflict, "gateway_token_not_configured", "gateway token is not configured") + return + } + gatewayToken, err := h.xworkmateVaultService.ReadSecret(ctx, locator) + if err != nil || strings.TrimSpace(gatewayToken) == "" { + respondError(c, http.StatusConflict, "gateway_token_unavailable", "gateway token is unavailable") + return + } + openclawURL := strings.TrimSpace(profile.OpenclawURL) + if openclawURL == "" { + respondError(c, http.StatusConflict, "gateway_endpoint_not_configured", "gateway endpoint is not configured") + return + } + + ticket.ConsumedAt = now + h.updateBridgeBootstrapTicket(ticket) + + c.JSON(http.StatusOK, bridgeBootstrapConsumeResponse{ + TicketID: ticket.TicketID, + TargetBridge: ticket.TargetBridge, + OpenclawURL: openclawURL, + AuthMode: "shared-token", + ExchangeToken: gatewayToken, + ExpiresAt: ticket.ExpiresAt.Format(time.RFC3339), + Scopes: append([]string(nil), ticket.Scopes...), + }) +} diff --git a/api/xworkmate_test.go b/api/xworkmate_test.go index c7bce19..7a09d46 100644 --- a/api/xworkmate_test.go +++ b/api/xworkmate_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -154,6 +155,141 @@ func TestBuildXWorkmateTokenConfiguredUsesSecretLocators(t *testing.T) { } } +func TestXWorkmateBridgeBootstrapTicketLifecycle(t *testing.T) { + gin.SetMode(gin.TestMode) + + vaultService := newMemoryXWorkmateVaultService() + router, user, token := newXWorkmateTestHarnessWithVault(t, nil, vaultService) + + profileBody, err := json.Marshal(map[string]any{ + "profile": map[string]any{ + "openclawUrl": "wss://openclaw.example.com", + "secretLocators": []map[string]any{ + { + "id": "locator-openclaw", + "provider": "vault", + "secretPath": "kv/openclaw", + "secretKey": "token", + "target": store.XWorkmateSecretLocatorTargetOpenclawGatewayToken, + "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.XWorkmateSecretLocatorTargetOpenclawGatewayToken, + }, "shared-token-value"); err != nil { + t.Fatalf("write secret: %v", err) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token) + createReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("expected bootstrap create success, got %d: %s", createRec.Code, createRec.Body.String()) + } + var created bridgeBootstrapIssueResponse + if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode bootstrap create response: %v", err) + } + if created.Ticket == "" || created.ShortCode == "" { + t.Fatalf("expected bootstrap ticket and short code, got %#v", created) + } + + lookupReq := httptest.NewRequest(http.MethodGet, "/api/auth/xworkmate/bridge/bootstrap/"+created.ShortCode, nil) + lookupReq.Header.Set("Authorization", "Bearer "+token) + lookupReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + lookupRec := httptest.NewRecorder() + router.ServeHTTP(lookupRec, lookupReq) + if lookupRec.Code != http.StatusOK { + t.Fatalf("expected bootstrap lookup success, got %d: %s", lookupRec.Code, lookupRec.Body.String()) + } + + consumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge)))) + consumeReq.Header.Set("Content-Type", "application/json") + consumeReq.Header.Set("X-Service-Token", "internal-test-token") + t.Setenv("INTERNAL_SERVICE_TOKEN", "internal-test-token") + consumeRec := httptest.NewRecorder() + router.ServeHTTP(consumeRec, consumeReq) + if consumeRec.Code != http.StatusOK { + t.Fatalf("expected bootstrap consume success, got %d: %s", consumeRec.Code, consumeRec.Body.String()) + } + var consumed bridgeBootstrapConsumeResponse + if err := json.Unmarshal(consumeRec.Body.Bytes(), &consumed); err != nil { + t.Fatalf("decode bootstrap consume response: %v", err) + } + if consumed.ExchangeToken != "shared-token-value" { + t.Fatalf("expected returned exchange token, got %#v", consumed) + } + if consumed.OpenclawURL != "wss://openclaw.example.com" { + t.Fatalf("expected returned openclaw url, got %#v", consumed) + } + + replayReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, created.Ticket, created.Bridge)))) + replayReq.Header.Set("Content-Type", "application/json") + replayReq.Header.Set("X-Service-Token", "internal-test-token") + replayRec := httptest.NewRecorder() + router.ServeHTTP(replayRec, replayReq) + if replayRec.Code != http.StatusConflict { + t.Fatalf("expected bootstrap replay conflict, got %d: %s", replayRec.Code, replayRec.Body.String()) + } + + revokeCreateReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap", bytes.NewReader([]byte(`{}`))) + revokeCreateReq.Header.Set("Content-Type", "application/json") + revokeCreateReq.Header.Set("Authorization", "Bearer "+token) + revokeCreateReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + revokeCreateRec := httptest.NewRecorder() + router.ServeHTTP(revokeCreateRec, revokeCreateReq) + if revokeCreateRec.Code != http.StatusOK { + t.Fatalf("expected second bootstrap create success, got %d: %s", revokeCreateRec.Code, revokeCreateRec.Body.String()) + } + var revokeCreated bridgeBootstrapIssueResponse + if err := json.Unmarshal(revokeCreateRec.Body.Bytes(), &revokeCreated); err != nil { + t.Fatalf("decode revoke bootstrap create response: %v", err) + } + + revokeReq := httptest.NewRequest(http.MethodPost, "/api/auth/xworkmate/bridge/bootstrap/"+revokeCreated.Ticket+"/revoke", nil) + revokeReq.Header.Set("Authorization", "Bearer "+token) + revokeReq.Header.Set("X-Forwarded-Host", store.SharedXWorkmateDomain) + revokeRec := httptest.NewRecorder() + router.ServeHTTP(revokeRec, revokeReq) + if revokeRec.Code != http.StatusOK { + t.Fatalf("expected bootstrap revoke success, got %d: %s", revokeRec.Code, revokeRec.Body.String()) + } + + revokedConsumeReq := httptest.NewRequest(http.MethodPost, "/api/internal/xworkmate/bridge/bootstrap/consume", bytes.NewReader([]byte(fmt.Sprintf(`{"ticket":%q,"bridge":%q}`, revokeCreated.Ticket, revokeCreated.Bridge)))) + revokedConsumeReq.Header.Set("Content-Type", "application/json") + revokedConsumeReq.Header.Set("X-Service-Token", "internal-test-token") + revokedConsumeRec := httptest.NewRecorder() + router.ServeHTTP(revokedConsumeRec, revokedConsumeReq) + if revokedConsumeRec.Code != http.StatusGone { + t.Fatalf("expected revoked bootstrap consume gone, got %d: %s", revokedConsumeRec.Code, revokedConsumeRec.Body.String()) + } + + if user.ID == "" { + t.Fatalf("expected created user id") + } +} + func TestUpdateAndGetXWorkmateProfileRoundTripsSecretLocators(t *testing.T) { gin.SetMode(gin.TestMode) @@ -421,6 +557,12 @@ func (f *flakyXWorkmateVaultService) DeleteSecret(ctx context.Context, locator s return nil } +func (f *flakyXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) { + _ = ctx + _ = locator + return "", errors.New("vault unavailable") +} + func (f *flakyXWorkmateVaultService) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) { _ = ctx _ = locator diff --git a/api/xworkmate_vault.go b/api/xworkmate_vault.go index 19778d7..9889b74 100644 --- a/api/xworkmate_vault.go +++ b/api/xworkmate_vault.go @@ -21,6 +21,7 @@ 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) + ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) } type XWorkmateVaultConfig struct { @@ -174,6 +175,27 @@ func (s *memoryXWorkmateVaultService) HasSecret(ctx context.Context, locator sto return ok, nil } +func (s *memoryXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error) { + _ = ctx + store.NormalizeXWorkmateSecretLocator(&locator) + if locator.SecretPath == "" || locator.SecretKey == "" { + return "", fmt.Errorf("vault locator is incomplete") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + secretMap := s.store[locator.SecretPath] + if secretMap == nil { + return "", fmt.Errorf("secret not found") + } + value, ok := secretMap[locator.SecretKey] + if !ok { + return "", fmt.Errorf("secret not found") + } + return value, nil +} + func (s *httpXWorkmateVaultService) WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error { store.NormalizeXWorkmateSecretLocator(&locator) if locator.SecretPath == "" || locator.SecretKey == "" { @@ -256,6 +278,22 @@ func (s *httpXWorkmateVaultService) HasSecret(ctx context.Context, locator store return ok, nil } +func (s *httpXWorkmateVaultService) ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (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 + } + value, ok := data[locator.SecretKey] + if !ok { + return "", fmt.Errorf("secret not found") + } + return value, 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 {