Skip to content

Add partner registration endpoint and partner KV registry#553

Draft
ChristianPavilonis wants to merge 2 commits intofeature/ec-kv-identity-graphfrom
feature/partner-registration
Draft

Add partner registration endpoint and partner KV registry#553
ChristianPavilonis wants to merge 2 commits intofeature/ec-kv-identity-graphfrom
feature/partner-registration

Conversation

@ChristianPavilonis
Copy link
Collaborator

Summary

  • Add Story 4 partner onboarding so operators can register partners through /admin/partners/register and persist validated partner records in KV.
  • Ensure partner API key auth is reliable by indexing apikey:{sha256} entries and failing registration when index writes fail, preventing partial-success states.
  • Keep admin auth aligned with current rollout by using existing Basic Auth protection and return 503 when ec.partner_store is not configured.

Changes

File Change
Cargo.toml Add workspace dependency on subtle for constant-time hash comparison support.
Cargo.lock Record lockfile updates for new dependency resolution.
crates/trusted-server-core/Cargo.toml Add subtle as a core crate dependency.
crates/trusted-server-core/src/ec/mod.rs Export new admin and partner EC modules.
crates/trusted-server-core/src/ec/admin.rs Add partner registration request/response models and /admin/partners/register handler with validation/defaulting.
crates/trusted-server-core/src/ec/partner.rs Add PartnerRecord, validation helpers, API key hashing, KV-backed PartnerStore, index lookup, and tests.
crates/trusted-server-adapter-fastly/src/main.rs Wire POST /admin/partners/register route and add partner-store requirement helper.
crates/trusted-server-core/src/settings.rs Add new admin endpoint to coverage checks and update admin endpoint validation tests.

Closes

Closes #537

Test plan

  • cargo test --workspace
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • JS tests: cd crates/js/lib && npx vitest run
  • JS format: cd crates/js/lib && npm run format
  • Docs format: cd docs && npm run format (fails on pre-existing formatting in docs/guide/configuration.md, not touched in this PR)
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: N/A

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

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.
@ChristianPavilonis ChristianPavilonis self-assigned this Mar 24, 2026
Copy link
Collaborator

@aram356 aram356 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: upsert overwrites the apikey:{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_hash uses != instead of ct_eq for the hash verification, inconsistent with verify_api_key and auth.rs (partner.rs:444)

❓ question

  • ts_pull_token stored 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_hash opens KV store twice: Calls open_store() then self.get() which calls open_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 String errors: validate_partner_id and validate_pull_sync_config return Result<(), String> — could return Result<(), Report<TrustedServerError>> directly to avoid .map_err(bad_request) wrapping at each call site

⛏ nitpick

  • api_key validated with trim() but hashed untrimmed: Subtly different from name/source_domain which are trimmed-then-used. A clarifying comment would help (admin.rs:183)
  • PR checklist says "Uses tracing macros" but code correctly uses log macros 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)?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

questionts_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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 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()?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinkingfind_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() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpickapi_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.

@ChristianPavilonis ChristianPavilonis marked this pull request as draft March 25, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants