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 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")

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"
"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

View File

@ -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 {