Files
sub2api/backend/internal/service/payment_config_providers.go

397 lines
14 KiB
Go
Raw Normal View History

package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Provider Instance CRUD ---
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
}
// ProviderInstanceResponse is the API response for a provider instance.
type ProviderInstanceResponse struct {
ID int64 `json:"id"`
ProviderKey string `json:"provider_key"`
Name string `json:"name"`
Config map[string]string `json:"config"`
SupportedTypes []string `json:"supported_types"`
Limits string `json:"limits"`
Enabled bool `json:"enabled"`
RefundEnabled bool `json:"refund_enabled"`
AllowUserRefund bool `json:"allow_user_refund"`
SortOrder int `json:"sort_order"`
PaymentMode string `json:"payment_mode"`
}
// ListProviderInstancesWithConfig returns provider instances with decrypted config.
func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Context) ([]ProviderInstanceResponse, error) {
instances, err := s.entClient.PaymentProviderInstance.Query().
Order(paymentproviderinstance.BySortOrder()).All(ctx)
if err != nil {
return nil, err
}
result := make([]ProviderInstanceResponse, 0, len(instances))
for _, inst := range instances {
resp := ProviderInstanceResponse{
ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name,
SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits,
Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled,
AllowUserRefund: inst.AllowUserRefund,
SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode,
}
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config)
if err != nil {
return nil, fmt.Errorf("decrypt config for instance %d: %w", inst.ID, err)
}
result = append(result, resp)
}
return result, nil
}
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
// decryptAndMaskConfig returns the stored config with sensitive fields omitted.
// Admin UIs display masked placeholders for these; the raw values never leave
// the server. Callers that need the full config (e.g. payment runtime) must
// use decryptConfig directly.
func (s *PaymentConfigService) decryptAndMaskConfig(providerKey, encrypted string) (map[string]string, error) {
cfg, err := s.decryptConfig(encrypted)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
masked := make(map[string]string, len(cfg))
for k, v := range cfg {
if isSensitiveProviderConfigField(providerKey, k) {
continue
}
masked[k] = v
}
return masked, nil
}
// pendingOrderStatuses are order statuses considered "in progress".
var pendingOrderStatuses = []string{
payment.OrderStatusPending,
payment.OrderStatusPaid,
payment.OrderStatusRecharging,
}
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
// providerSensitiveConfigFields is the authoritative list of config keys that
// are treated as secrets per provider. Must stay in sync with the frontend
// definition at frontend/src/components/payment/providerConfig.ts
// (PROVIDER_CONFIG_FIELDS, fields with sensitive: true).
//
// Key matching is case-insensitive. Non-listed keys (e.g. appId, notifyUrl,
// stripe publishableKey) are returned in plaintext by the admin GET API.
var providerSensitiveConfigFields = map[string]map[string]struct{}{
payment.TypeEasyPay: {"pkey": {}},
payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}},
payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}},
payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}},
}
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
func isSensitiveProviderConfigField(providerKey, fieldName string) bool {
fields, ok := providerSensitiveConfigFields[providerKey]
if !ok {
return false
}
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
_, found := fields[strings.ToLower(fieldName)]
return found
}
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
return s.entClient.PaymentOrder.Query().
Where(
paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
paymentorder.StatusIn(pendingOrderStatuses...),
).Count(ctx)
}
func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
return s.entClient.PaymentOrder.Query().
Where(
paymentorder.PlanIDEQ(planID),
paymentorder.StatusIn(pendingOrderStatuses...),
).Count(ctx)
}
var validProviderKeys = map[string]bool{
payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true,
}
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
typesStr := joinTypes(req.SupportedTypes)
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
return nil, err
}
enc, err := s.encryptConfig(req.Config)
if err != nil {
return nil, err
}
allowUserRefund := req.AllowUserRefund && req.RefundEnabled
return s.entClient.PaymentProviderInstance.Create().
SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
SetSupportedTypes(typesStr).SetEnabled(req.Enabled).SetPaymentMode(req.PaymentMode).
SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
SetAllowUserRefund(allowUserRefund).
Save(ctx)
}
func validateProviderRequest(providerKey, name, supportedTypes string) error {
if strings.TrimSpace(name) == "" {
return infraerrors.BadRequest("VALIDATION_ERROR", "provider name is required")
}
if !validProviderKeys[providerKey] {
return infraerrors.BadRequest("VALIDATION_ERROR", fmt.Sprintf("invalid provider key: %s", providerKey))
}
// supported_types can be empty (provider accepts no payment types until configured)
return nil
}
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
// boilerplate and pending-order safety checks.
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
var cachedInst *dbent.PaymentProviderInstance
loadInst := func() (*dbent.PaymentProviderInstance, error) {
if cachedInst != nil {
return cachedInst, nil
}
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("load provider instance: %w", err)
}
cachedInst = inst
return inst, nil
}
if req.Config != nil {
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
inst, err := loadInst()
if err != nil {
return nil, err
}
hasSensitive := false
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
for k, v := range req.Config {
if v != "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
hasSensitive = true
break
}
}
if hasSensitive {
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return nil, fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
}
if req.Enabled != nil && !*req.Enabled {
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return nil, fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
if req.Name != nil {
u.SetName(*req.Name)
}
if req.Config != nil {
merged, err := s.mergeConfig(ctx, id, req.Config)
if err != nil {
return nil, err
}
enc, err := s.encryptConfig(merged)
if err != nil {
return nil, err
}
u.SetConfig(enc)
}
if req.SupportedTypes != nil {
// Check pending orders before removing payment types
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return nil, fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
// Load current instance to compare types
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("load provider instance: %w", err)
}
oldTypes := strings.Split(inst.SupportedTypes, ",")
newTypes := req.SupportedTypes
for _, ot := range oldTypes {
ot = strings.TrimSpace(ot)
if ot == "" {
continue
}
found := false
for _, nt := range newTypes {
if strings.TrimSpace(nt) == ot {
found = true
break
}
}
if !found {
return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has pending orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
}
u.SetSupportedTypes(joinTypes(req.SupportedTypes))
}
if req.Enabled != nil {
u.SetEnabled(*req.Enabled)
}
if req.SortOrder != nil {
u.SetSortOrder(*req.SortOrder)
}
if req.Limits != nil {
u.SetLimits(*req.Limits)
}
if req.RefundEnabled != nil {
u.SetRefundEnabled(*req.RefundEnabled)
// Cascade: turning off refund_enabled also disables allow_user_refund
if !*req.RefundEnabled {
u.SetAllowUserRefund(false)
}
}
if req.AllowUserRefund != nil {
// Only allow enabling when refund_enabled is (or will be) true
if *req.AllowUserRefund {
refundEnabled := false
if req.RefundEnabled != nil {
refundEnabled = *req.RefundEnabled
} else {
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err == nil {
refundEnabled = inst.RefundEnabled
}
}
if refundEnabled {
u.SetAllowUserRefund(true)
}
} else {
u.SetAllowUserRefund(false)
}
}
if req.PaymentMode != nil {
u.SetPaymentMode(*req.PaymentMode)
}
return u.Save(ctx)
}
// GetUserRefundEligibleInstanceIDs returns provider instance IDs that allow user refund.
func (s *PaymentConfigService) GetUserRefundEligibleInstanceIDs(ctx context.Context) ([]string, error) {
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(
paymentproviderinstance.RefundEnabledEQ(true),
paymentproviderinstance.AllowUserRefundEQ(true),
).Select(paymentproviderinstance.FieldID).All(ctx)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(instances))
for _, inst := range instances {
ids = append(ids, strconv.FormatInt(int64(inst.ID), 10))
}
return ids, nil
}
func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("load existing provider: %w", err)
}
existing, err := s.decryptConfig(inst.Config)
if err != nil {
return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
}
if existing == nil {
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
existing = map[string]string{}
}
for k, v := range newConfig {
feat(payment): redact provider secrets in admin config API Admin GET /api/v1/admin/payment/providers previously returned every config value — including privateKey / apiV3Key / secretKey etc. — verbatim. Any future XSS on the admin UI would hand attackers the full set of production payment credentials, and the plaintext values sat unnecessarily in browser memory for every operator. Treat those fields as write-only from the admin surface: - decryptAndMaskConfig() strips sensitive keys from the GET response. The authoritative list is an explicit per-provider registry that mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag: alipay → privateKey, publicKey, alipayPublicKey wxpay → privateKey, apiV3Key, publicKey stripe → secretKey, webhookSecret (publishableKey stays plain) easypay → pkey Payment runtime still reads the full config via decryptConfig, so nothing at the gateway changes. - mergeConfig() treats an empty value for a sensitive key as "leave unchanged" — the admin UI omits unchanged secrets so operators can tweak non-sensitive settings without re-entering credentials. - Admin dialog (PaymentProviderDialog.vue): * secret inputs get autocomplete="new-password", data-1p-ignore, data-lpignore and data-bwignore so password managers do not offer to save provider credentials * in edit mode the required-field check skips sensitive fields (empty is the "keep existing" signal) and the placeholder shows "leave empty to keep" instead of the default example value * create mode still requires every non-optional field, including secrets, since there is nothing to preserve - Unit test renamed to TestIsSensitiveProviderConfigField, covers the per-provider registry and specifically asserts that Stripe's publishableKey is NOT treated as a secret.
2026-04-19 01:46:50 +08:00
// Preserve existing secrets when the client submits an empty value
// (admin UI omits the value to indicate "leave unchanged").
if v == "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
continue
}
existing[k] = v
}
return existing, nil
}
// decryptConfig parses a stored provider config.
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
// ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
// legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
// letting the admin re-enter the config via the UI to complete the migration.
//
// TODO(deprecated-legacy-ciphertext): The AES fallback branch is a transitional
// shim for pre-plaintext records. Remove it (and the encryptionKey field) after
// a few releases once all live deployments have re-saved their provider configs.
func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
if stored == "" {
return nil, nil
}
var cfg map[string]string
if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
return cfg, nil
}
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
if len(s.encryptionKey) == payment.AES256KeySize {
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
return cfg, nil
}
}
}
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
"stored_len", len(stored))
return nil, nil
}
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return infraerrors.Conflict("PENDING_ORDERS",
fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
}
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
}
// encryptConfig serialises a provider config for storage.
// New records are written as plaintext JSON; the historical AES-GCM wrapping
// has been dropped but decryptConfig still accepts old ciphertext during migration.
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
return string(data), nil
}