Skip to content

Commit a798ad4

Browse files
authored
feat: admin whitelist API (#313)
* feat(api): admin whitelist API Add admin endpoints to manage the registration whitelist, gated by a static bearer token (Authorization: Bearer), enabled via the `admin` config block with the token supplied through ADMIN_API_KEY. Endpoints (under /admin): - POST /admin/whitelist add/upsert an address (idempotent) - DELETE /admin/whitelist/{address} remove (404 when not whitelisted) - GET /admin/whitelist cursor-paginated list Details: - New whitelist domain in pkg/user/whitelist: a Service providing the authorization gate (Checker) plus admin add/remove/list (Manager), with EIP-55 validation/normalization. The admin HTTP handlers and bearer middleware live here. - userstore: RemoveFromWhitelist reports rows-affected (for 404); ListWhitelist is cursor-based (ORDER BY evm_address, the unique PK); whitelist lookups (IsWhitelisted/Remove) are case-insensitive via LOWER(evm_address), backed by a functional index (migration 15). - config: optional AdminAPI block; api_key required_if enabled. - e2e: whitelisting now goes through the admin endpoint instead of a direct DB insert; adds full lifecycle + pagination tests. * fix(api): address review feedback on whitelist - ListWhitelist: order and compare the cursor on LOWER(evm_address) so cursor pagination is case-insensitive and stable for mixed-case (EIP-55) addresses, consistent with the IsWhitelisted/Remove lookups and the LOWER(evm_address) functional index. Uses OrderExpr so the LOWER(...) is emitted as raw SQL rather than quoted as a column. Adds a mixed-case cursor test. - writeJSON: marshal the payload before writing the status line so a serialization failure returns 500 instead of a 200 with a truncated body (whitelist admin + registration handlers). * fix(api): make admin opt-in via ADMIN_API_ENABLED admin.enabled was hardcoded true in the default configs, so every process that loads the api-server config — including the bootstrap (bootstrap-demo / bootstrap-bridge call config.LoadAPIServer) — required ADMIN_API_KEY to pass required_if validation. The bootstrap container has no such token, so its config load failed and the DEMO TokenConfig / bridge TransferFactory were never created, breaking the transfer/balance/registry e2e tests. Make admin opt-in: enabled is now driven by ${ADMIN_API_ENABLED} (default off, mirroring skip_canton_sig_verify). The api-server service sets it to true in compose; the bootstrap leaves it unset, so its config load no longer needs a token. Also keeps admin off-by-default for mainnet.
1 parent e23ed70 commit a798ad4

34 files changed

Lines changed: 1732 additions & 144 deletions

docker-compose.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ services:
299299
CANTON_AUTH_CLIENT_SECRET: "${CANTON_AUTH_CLIENT_SECRET:-local-test-secret}"
300300
CANTON_MASTER_KEY: "${CANTON_MASTER_KEY}" # Generate with: openssl rand -base64 32
301301
SKIP_CANTON_SIG_VERIFY: "${SKIP_CANTON_SIG_VERIFY:-false}" # Set to "true" for local testing
302+
# Admin API: enabled on the api-server only. The bootstrap loads the same
303+
# config but leaves ADMIN_API_ENABLED unset, so admin stays off there and
304+
# its config load doesn't require a token. The key default MUST match
305+
# defaultAdminAPIKey in tests/e2e/devstack/shim/apiserver.go.
306+
ADMIN_API_ENABLED: "true"
307+
ADMIN_API_KEY: "${ADMIN_API_KEY:-local-admin-key}"
302308
depends_on:
303309
bootstrap:
304310
condition: service_completed_successfully

pkg/app/api/server.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,18 @@ func (s *Server) Run() error {
160160
)
161161
}
162162

163-
router := s.setupRouter(svcs.evmStore, wl, cantonClient, svcs.tokenService, svcs.regSvc, svcs.transferSvc, metrics, logger)
163+
// Admin config is optional (nil when the `admin` block is omitted). The token
164+
// is resolved by the config loader (api_key: "${ADMIN_API_KEY}") and validated
165+
// as required when enabled, so setupRouter can read it straight off the value.
166+
var adminCfg config.AdminAPI
167+
if cfg.Admin != nil {
168+
adminCfg = *cfg.Admin
169+
}
170+
171+
router := s.setupRouter(
172+
svcs.evmStore, wl, cantonClient, svcs.tokenService, svcs.regSvc, svcs.transferSvc,
173+
adminCfg, metrics, logger,
174+
)
164175

165176
s.registerServers(g, gCtx, router, logger)
166177

@@ -364,11 +375,12 @@ func (s *Server) registerServers(g *errgroup.Group, gCtx context.Context, router
364375

365376
func (s *Server) setupRouter(
366377
evmStore ethrpc.Store,
367-
wl whitelist.Checker,
378+
wl *whitelist.Service,
368379
cantonClient *canton.Client,
369380
tokenService *token.Service,
370-
registrationService userservice.Service,
381+
userService userservice.Service,
371382
transferSvc transfer.Service,
383+
adminCfg config.AdminAPI,
372384
metrics *apphttp.HTTPMetrics,
373385
logger *zap.Logger,
374386
) chi.Router {
@@ -392,7 +404,12 @@ func (s *Server) setupRouter(
392404
token.RegisterRoutes(r, tokenService, logger)
393405

394406
// Registration endpoints
395-
userservice.RegisterRoutes(r, registrationService, logger)
407+
userservice.RegisterRoutes(r, userService, logger)
408+
409+
// Admin endpoints (whitelist management), gated by a static bearer token.
410+
if adminCfg.Enabled {
411+
whitelist.RegisterAdminRoutes(r, wl, adminCfg.APIKey, logger)
412+
}
396413

397414
// Non-custodial transfer endpoints (prepare/execute)
398415
transfer.RegisterRoutes(r, transferSvc, logger)

pkg/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ type APIServer struct {
8282
SkipCantonSigVerify bool `yaml:"skip_canton_sig_verify" default:"false"`
8383
SkipWhitelistCheck bool `yaml:"skip_whitelist_check" default:"false"`
8484
CORSOrigins []string `yaml:"cors" default:"[\"*\"]"`
85+
Admin *AdminAPI `yaml:"admin" default:"-"` // nil disables the admin endpoints
86+
}
87+
88+
// AdminAPI configures the optional admin HTTP endpoints (whitelist management).
89+
// Access is gated by a static bearer token. Supply it via env substitution
90+
// (api_key: "${ADMIN_API_KEY}")
91+
type AdminAPI struct {
92+
// Enabled turns the admin endpoints on. When false (the default) no admin
93+
// routes are mounted.
94+
Enabled bool `yaml:"enabled" default:"false"`
95+
// APIKey is the admin bearer token. Required when Enabled is true.
96+
APIKey string `yaml:"api_key" validate:"required_if=Enabled true"`
8597
}
8698

8799
// RelayerServer represents the application configuration for relayer.

pkg/config/config_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,18 @@ func TestLoadAPIServer_RequiredValidation(t *testing.T) {
257257
}
258258
}
259259

260+
func TestLoadAPIServer_AdminEnabledRequiresKey(t *testing.T) {
261+
path := testConfigPath(t, "admin-enabled-no-key.api.yaml")
262+
263+
_, err := LoadAPIServer(path)
264+
if err == nil {
265+
t.Fatal("expected required validation error for admin.api_key, got nil")
266+
}
267+
if !strings.Contains(strings.ToLower(err.Error()), "api_key") {
268+
t.Fatalf("expected admin.api_key validation error, got: %v", err)
269+
}
270+
}
271+
260272
func TestLoadAPIServer_InvalidDatabaseURL(t *testing.T) {
261273
path := testConfigPath(t, "invalid-database-url.api.yaml")
262274

@@ -409,4 +421,5 @@ func setDefaultConfigEnv(t *testing.T) {
409421
t.Setenv("INDEXER_URL", "http://indexer:8082")
410422
t.Setenv("CANTON_USDCX_ISSUER_PARTY", "usdcx-issuer::1220test")
411423
t.Setenv("CANTON_USDCX_REGISTRY_URL", "http://usdcx-registry:8090")
424+
t.Setenv("ADMIN_API_KEY", "test-admin-key")
412425
}

pkg/config/defaults/config.api-server.docker.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,5 +134,12 @@ key_management:
134134
skip_canton_sig_verify: ${SKIP_CANTON_SIG_VERIFY}
135135
skip_whitelist_check: false
136136

137+
# Admin API for whitelist management (POST/DELETE/GET /admin/whitelist).
138+
# Enabled by ADMIN_API_ENABLED=true (default off); gated by a static bearer
139+
# token supplied via ADMIN_API_KEY (required when enabled).
140+
admin:
141+
enabled: ${ADMIN_API_ENABLED}
142+
api_key: "${ADMIN_API_KEY}"
143+
137144
cors:
138145
- "*"

pkg/config/defaults/config.api-server.local-devnet.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,12 @@ key_management:
125125

126126
skip_whitelist_check: true
127127

128+
# Admin API for whitelist management (POST/DELETE/GET /admin/whitelist).
129+
# Enabled by ADMIN_API_ENABLED=true (default off); gated by a static bearer
130+
# token supplied via ADMIN_API_KEY (required when enabled).
131+
admin:
132+
enabled: ${ADMIN_API_ENABLED}
133+
api_key: "${ADMIN_API_KEY}"
134+
128135
cors:
129136
- "*"

pkg/config/defaults/config.api-server.mainnet.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,12 @@ key_management:
9999

100100
skip_canton_sig_verify: false
101101

102+
# Admin API for whitelist management (POST/DELETE/GET /admin/whitelist).
103+
# Enabled by ADMIN_API_ENABLED=true (default off); gated by a static bearer
104+
# token supplied via ADMIN_API_KEY (required when enabled).
105+
admin:
106+
enabled: ${ADMIN_API_ENABLED}
107+
api_key: "${ADMIN_API_KEY}"
108+
102109
cors:
103110
- "${CORS_ALLOWED_ORIGINS}"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
server:
2+
host: "0.0.0.0"
3+
port: 8081
4+
5+
database:
6+
url: "postgres://postgres:pass@localhost:5432/erc20_api"
7+
ssl_mode: "disable"
8+
9+
canton:
10+
domain_id: "domain"
11+
issuer_party: "issuer"
12+
ledger:
13+
rpc_url: "localhost:5011"
14+
tls: {}
15+
auth:
16+
client_id: "client"
17+
client_secret: "secret"
18+
audience: "audience"
19+
token_url: "http://localhost/token"
20+
identity:
21+
package_id: "identity-package"
22+
token:
23+
cip56_package_id: "cip56-package"
24+
splice_transfer_package_id: "splice-package"
25+
splice_holding_package_id: "test-splice-holding-pkg-id"
26+
27+
token:
28+
supported_tokens:
29+
"0x0000000000000000000000000000000000000001":
30+
name: "PROMPT"
31+
symbol: "PROMPT"
32+
decimals: 18
33+
instrument_id: "PROMPT"
34+
35+
eth_rpc:
36+
chain_id: 31337
37+
38+
logging:
39+
level: "info"
40+
format: "json"
41+
42+
key_management: {}
43+
monitoring:
44+
enabled: false
45+
46+
# Admin enabled but no api_key — must fail required_if validation.
47+
admin:
48+
enabled: true

pkg/ethrpc/service/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const (
9696
)
9797

9898
// NewService creates a new ethService. whitelist authorizes transaction senders
99-
// in SendRawTransaction and must be non-nil; pass whitelist.New(src, true) to
99+
// in SendRawTransaction and must be non-nil; pass whitelist.New(store, true) to
100100
// disable the gate (skip_whitelist_check).
101101
func NewService(
102102
cfg *ethrpc.Config,

pkg/user/service/http.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,15 @@ func (h *HTTP) getUser(w http.ResponseWriter, r *http.Request) error {
145145
}
146146

147147
func (h *HTTP) writeJSON(w http.ResponseWriter, status int, data any) {
148+
// Marshal before writing the status line so a serialization failure yields a
149+
// 500 rather than the intended status with a truncated body.
150+
buf, err := json.Marshal(data)
151+
if err != nil {
152+
h.logger.Error("failed to marshal JSON response", zap.Error(err))
153+
http.Error(w, "internal server error", http.StatusInternalServerError)
154+
return
155+
}
148156
w.Header().Set("Content-Type", "application/json")
149157
w.WriteHeader(status)
150-
if err := json.NewEncoder(w).Encode(data); err != nil {
151-
h.logger.Error("failed to write JSON response", zap.Error(err))
152-
}
158+
_, _ = w.Write(buf)
153159
}

0 commit comments

Comments
 (0)