Add bridge bootstrap ticket flow

This commit is contained in:
Haitao Pan 2026-04-10 15:36:05 +08:00
parent f6cae1d8e7
commit fdbef2ab29
4 changed files with 512 additions and 0 deletions

View File

@ -39,6 +39,8 @@ const defaultPasswordResetTTL = 30 * time.Minute
const maxMFAVerificationAttempts = 5 const maxMFAVerificationAttempts = 5
const defaultMFALockoutDuration = 5 * time.Minute const defaultMFALockoutDuration = 5 * time.Minute
const defaultOAuthExchangeCodeTTL = 5 * time.Minute const defaultOAuthExchangeCodeTTL = 5 * time.Minute
const defaultBridgeBootstrapTTL = 5 * time.Minute
const defaultBridgeBootstrapTarget = "https://xworkmate-bridge.svc.plus"
const sessionCookieName = "xc_session" const sessionCookieName = "xc_session"
@ -74,6 +76,10 @@ type handler struct {
oauthExchangeCodes map[string]oauthExchangeCode oauthExchangeCodes map[string]oauthExchangeCode
oauthExchangeMu sync.RWMutex oauthExchangeMu sync.RWMutex
oauthExchangeTTL time.Duration oauthExchangeTTL time.Duration
bridgeBootstrapTickets map[string]bridgeBootstrapTicket
bridgeBootstrapByCode map[string]string
bridgeBootstrapMu sync.RWMutex
bridgeBootstrapTTL time.Duration
metricsProvider service.UserMetricsProvider metricsProvider service.UserMetricsProvider
agentStatusReader agentStatusReader agentStatusReader agentStatusReader
tokenService *auth.TokenService tokenService *auth.TokenService
@ -303,6 +309,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
passwordResets: make(map[string]passwordReset), passwordResets: make(map[string]passwordReset),
oauthExchangeCodes: make(map[string]oauthExchangeCode), oauthExchangeCodes: make(map[string]oauthExchangeCode),
oauthExchangeTTL: defaultOAuthExchangeCodeTTL, oauthExchangeTTL: defaultOAuthExchangeCodeTTL,
bridgeBootstrapTickets: make(map[string]bridgeBootstrapTicket),
bridgeBootstrapByCode: make(map[string]string),
bridgeBootstrapTTL: defaultBridgeBootstrapTTL,
} }
for _, opt := range opts { for _, opt := range opts {
@ -361,6 +370,9 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets) authProtected.GET("/xworkmate/secrets", h.getXWorkmateSecrets)
authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret) authProtected.PUT("/xworkmate/secrets/:target", h.putXWorkmateSecret)
authProtected.DELETE("/xworkmate/secrets/:target", h.deleteXWorkmateSecret) 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/provision", h.provisionTOTP)
authProtected.POST("/mfa/totp/verify", h.verifyTOTP) 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("/network/identities", h.internalNetworkIdentities)
internalGroup.GET("/policy/:accountUUID", h.internalAccountPolicy) internalGroup.GET("/policy/:accountUUID", h.internalAccountPolicy)
internalGroup.POST("/nodes/heartbeat", h.internalNodeHeartbeat) 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/...) // Public /api routes for admin/management (expected by frontend at /api/admin/...)
apiGroup := r.Group("/api") apiGroup := r.Group("/api")

View File

@ -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...),
})
}

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "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) { func TestUpdateAndGetXWorkmateProfileRoundTripsSecretLocators(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@ -421,6 +557,12 @@ func (f *flakyXWorkmateVaultService) DeleteSecret(ctx context.Context, locator s
return nil 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) { func (f *flakyXWorkmateVaultService) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) {
_ = ctx _ = ctx
_ = locator _ = locator

View File

@ -21,6 +21,7 @@ type xworkmateVaultService interface {
WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error
DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error DeleteSecret(ctx context.Context, locator store.XWorkmateSecretLocator) error
HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error) HasSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (bool, error)
ReadSecret(ctx context.Context, locator store.XWorkmateSecretLocator) (string, error)
} }
type XWorkmateVaultConfig struct { type XWorkmateVaultConfig struct {
@ -174,6 +175,27 @@ func (s *memoryXWorkmateVaultService) HasSecret(ctx context.Context, locator sto
return ok, nil 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 { func (s *httpXWorkmateVaultService) WriteSecret(ctx context.Context, locator store.XWorkmateSecretLocator, value string) error {
store.NormalizeXWorkmateSecretLocator(&locator) store.NormalizeXWorkmateSecretLocator(&locator)
if locator.SecretPath == "" || locator.SecretKey == "" { if locator.SecretPath == "" || locator.SecretKey == "" {
@ -256,6 +278,22 @@ func (s *httpXWorkmateVaultService) HasSecret(ctx context.Context, locator store
return ok, nil 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) { func (s *httpXWorkmateVaultService) readSecretMap(ctx context.Context, secretPath string) (map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.dataURL(secretPath), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.dataURL(secretPath), nil)
if err != nil { if err != nil {