Add partner registration endpoint and partner KV registry#553
Add partner registration endpoint and partner KV registry#553ChristianPavilonis wants to merge 2 commits intofeature/ec-kv-identity-graphfrom
Conversation
Implements Story 4 partner onboarding so operators can register partners in KV with validated config and API-key hash indexing, while keeping admin access on existing basic auth and returning 503 when partner store config is missing.
aram356
left a comment
There was a problem hiding this comment.
Summary
Adds partner registration (POST /admin/partners/register) with KV-backed PartnerStore, API key hash indexing, and solid input validation. The write-order and rollback logic in upsert is well designed for a non-atomic KV store. Two security issues need fixing before merge.
Blocking
🔧 wrench
- API key collision silently hijacks auth:
upsertoverwrites theapikey:{hash}index without checking if it already belongs to a different partner, silently breaking the first partner's batch sync auth (partner.rs:278) - Non-constant-time hash comparison in auth path:
find_by_api_key_hashuses!=instead ofct_eqfor the hash verification, inconsistent withverify_api_keyandauth.rs(partner.rs:444)
❓ question
ts_pull_tokenstored in plaintext: API key is properly hashed, but the outbound bearer token is stored as cleartext in KV. Is this intentional? (partner.rs:95)
Non-blocking
🤔 thinking
- No body size limit on registration endpoint: Unbounded
take_body_bytes()— other handlers enforce limits. Lower risk since it's behind admin auth, but a cap would be defensive (admin.rs:154) find_by_api_key_hashopens KV store twice: Callsopen_store()thenself.get()which callsopen_store()again (partner.rs:406)- Upsert write order diverges from tech spec: Spec says primary-first, implementation does index-first. The implementation's order is arguably better (prevents success with broken index), but the spec should be updated to match
♻️ refactor
- Validation helpers return
Stringerrors:validate_partner_idandvalidate_pull_sync_configreturnResult<(), String>— could returnResult<(), Report<TrustedServerError>>directly to avoid.map_err(bad_request)wrapping at each call site
⛏ nitpick
api_keyvalidated withtrim()but hashed untrimmed: Subtly different fromname/source_domainwhich are trimmed-then-used. A clarifying comment would help (admin.rs:183)- PR checklist says "Uses
tracingmacros" but code correctly useslogmacros per CLAUDE.md — just a template issue
CI Status
- prepare integration artifacts: PASS
- integration tests: FAIL (pre-existing on base branch)
- browser integration tests: FAIL (pre-existing on base branch)
| let old_api_key_hash = existing.as_ref().map(|r| r.api_key_hash.clone()); | ||
|
|
||
| let index_key = format!("{APIKEY_INDEX_PREFIX}{}", record.api_key_hash); | ||
| let previous_index_partner_id = self.read_index_partner_id(&store, &index_key)?; |
There was a problem hiding this comment.
🔧 wrench — API key collision silently hijacks another partner's auth
upsert writes the apikey:{hash} index without checking whether it already points to a different partner. If partner A and partner B register with the same API key, the index silently overwrites to point to B, breaking A's batch sync auth with no error.
Fix: After reading previous_index_partner_id, reject if it belongs to a different partner:
if let Some(ref existing_owner) = previous_index_partner_id {
if *existing_owner != record.id {
return Err(Report::new(TrustedServerError::BadRequest {
message: "API key is already registered to another partner".to_owned(),
}));
}
}This should go before step 1 (write new API key index).
|
|
||
| // Verify the stored hash matches — guards against stale index from | ||
| // key rotation. | ||
| if record.api_key_hash != hash { |
There was a problem hiding this comment.
🔧 wrench — Non-constant-time hash comparison in auth path
This uses standard !=, not constant-time comparison. verify_api_key correctly uses ct_eq, and auth.rs does the same for Basic Auth, but this method — the primary batch sync auth path per the spec — leaks timing information.
Both values are hex-encoded SHA-256 so practical risk is limited, but it's inconsistent with the security posture elsewhere.
Fix:
use subtle::ConstantTimeEq;
let stored = record.api_key_hash.as_bytes();
let lookup = hash.as_bytes();
if !bool::from(stored.ct_eq(lookup)) {|
|
||
| /// Validates a partner ID format and checks against reserved names. | ||
| /// | ||
| /// # Errors |
There was a problem hiding this comment.
❓ question — ts_pull_token stored in plaintext in KV
The API key is properly hashed before storage, but ts_pull_token (outbound bearer token for pull sync) is stored as cleartext in KV. Anyone with KV store read access gets all partner bearer tokens. Is this intentional? Is there a plan for envelope encryption or using Fastly Secret Store for these credentials?
| mut req: Request, | ||
| ) -> Result<Response, Report<TrustedServerError>> { | ||
| // Parse request body. | ||
| let body_bytes = req.take_body_bytes(); |
There was a problem hiding this comment.
🤔 thinking — No body size limit on registration endpoint
Other handlers enforce body size limits (MAX_REWRITABLE_BODY_SIZE in creative.rs, max_beacon_body_size in GTM). This endpoint does unbounded take_body_bytes(). Since it's behind admin Basic Auth the risk is lower, but a compromised credential or misconfigured client could cause a large allocation. A reasonable limit (e.g., 64 KB) would be defensive.
| &self, | ||
| hash: &str, | ||
| ) -> Result<Option<PartnerRecord>, Report<TrustedServerError>> { | ||
| let store = self.open_store()?; |
There was a problem hiding this comment.
🤔 thinking — find_by_api_key_hash opens the KV store twice
This method calls self.open_store() to read the index, then calls self.get() which internally calls self.open_store() again. On Fastly KVStore::open() is likely cheap, but it's still redundant. Consider adding a private get_with_store(&KVStore, &str) method, or inlining the record fetch to reuse the store handle.
|
|
||
| // Validate and normalize required fields. | ||
| let name = normalize_required_text(&name, "name")?; | ||
| if api_key.trim().is_empty() { |
There was a problem hiding this comment.
⛏ nitpick — api_key validated with trim() but hashed untrimmed
The empty check uses api_key.trim().is_empty() but hash_api_key(&api_key) hashes the raw value. A key like " abc " passes validation but hashes with the spaces. This is likely correct (don't mutate secrets), but it's subtly different from name/source_domain which are trimmed-then-used. A clarifying comment would help.
Summary
/admin/partners/registerand persist validated partner records in KV.apikey:{sha256}entries and failing registration when index writes fail, preventing partial-success states.ec.partner_storeis not configured.Changes
Cargo.tomlsubtlefor constant-time hash comparison support.Cargo.lockcrates/trusted-server-core/Cargo.tomlsubtleas a core crate dependency.crates/trusted-server-core/src/ec/mod.rsadminandpartnerEC modules.crates/trusted-server-core/src/ec/admin.rs/admin/partners/registerhandler with validation/defaulting.crates/trusted-server-core/src/ec/partner.rsPartnerRecord, validation helpers, API key hashing, KV-backedPartnerStore, index lookup, and tests.crates/trusted-server-adapter-fastly/src/main.rs/admin/partners/registerroute and add partner-store requirement helper.crates/trusted-server-core/src/settings.rsCloses
Closes #537
Test plan
cargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest runcd crates/js/lib && npm run formatcd docs && npm run format(fails on pre-existing formatting indocs/guide/configuration.md, not touched in this PR)cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)