Files
insights/rust/capture/tests/integration_analytics_restrictions.rs
zeekay 19fdf55ceb feat(auth): one key scheme, minted and read in one place
A key now opens with what it is allowed to touch. `pk-` is publishable and
safe in a browser; `sk-` is secret and never leaves a server. The five marks
it replaces all began `ph` -- the upstream's initials -- and they were the
last of its brand a customer read back to us, in every SDK snippet. No word
rule could reach them, because `ph` is not a word.

The marks were also not telling the truth. The constant said `phc_` while
every live project token said `hi_`, so the middleware that routes on the
constant matched no real token at all. Two spellings existed and one was
fiction. Minting now happens in exactly one function and reading in exactly
one, so a second spelling has nowhere to live.

`phx_` (personal) and `phs_` (project secret) both become `sk-`. The mark
now says only that a value is secret; which store holds it is settled by
asking. That was already how personal keys worked -- keys minted before the
marks carry none, so the store always decided -- and both secret resolvers
already returned None on a miss and fell through, so one shared extractor
replaces the two that could drift apart.

OAuth's access and refresh tokens stay distinguishable as `at-` and `rt-`,
because the parser tells them apart before either is looked up, and the
domain-verification token becomes `vt-` rather than keeping a sixth mint of
its own.

`bin/debrand --check` now refuses the marks as a shape, so a seventh cannot
be coined. Two rules that were about to rename things that are not ours to
spell are bounded: `Lemon` no longer eats `LemonSqueezy`, and the `hog_flow`
rules no longer reach a Postgres table, a Datastore table or a route.

Keys are stored hashed, so a mark cannot be rewritten in place: the three
live project tokens have to be reminted.
2026-08-08 19:42:44 -07:00

622 lines
18 KiB
Rust

#[path = "common/integration_utils.rs"]
mod integration_utils;
use async_trait::async_trait;
use axum::http::StatusCode;
use axum::Router;
use axum_test_helper::TestClient;
use capture::api::CaptureError;
use capture::config::{AiRouting, CaptureMode};
use capture::event_restrictions::{
EventRestrictionService, Pipeline, Restriction, RestrictionManager, RestrictionScope,
RestrictionType,
};
use capture::quota_limiters::CaptureQuotaLimiter;
use capture::router::router;
use capture::sinks::Event;
use capture::time::TimeSource;
use capture::v0_request::{DataType, ProcessedEvent};
use chrono::{DateTime, Utc};
use common_redis::MockRedisClient;
use integration_utils::{test_lifecycle_handlers, DEFAULT_CONFIG, DEFAULT_TEST_TIME};
use limiters::token_dropper::TokenDropper;
use serde_json::{json, Value};
use std::sync::Arc;
use std::time::Duration;
struct FixedTime {
pub time: DateTime<Utc>,
}
impl TimeSource for FixedTime {
fn current_time(&self) -> DateTime<Utc> {
self.time
}
}
#[derive(Clone)]
struct CapturingSink {
events: Arc<tokio::sync::Mutex<Vec<ProcessedEvent>>>,
}
impl CapturingSink {
fn new() -> Self {
Self {
events: Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
async fn get_events(&self) -> Vec<ProcessedEvent> {
self.events.lock().await.clone()
}
}
#[async_trait]
impl Event for CapturingSink {
async fn send(&self, event: ProcessedEvent) -> Result<(), CaptureError> {
self.events.lock().await.push(event);
Ok(())
}
async fn send_batch(&self, events: Vec<ProcessedEvent>) -> Result<(), CaptureError> {
self.events.lock().await.extend(events);
Ok(())
}
}
async fn setup_analytics_router_with_restriction(
restriction_type: RestrictionType,
restriction_pipeline: Pipeline,
token: &str,
ai_routing: AiRouting,
ai_events_overflow_enabled: bool,
) -> (Router, CapturingSink) {
let (readiness, liveness, _monitor) = test_lifecycle_handlers();
let sink = CapturingSink::new();
let sink_clone = sink.clone();
let timesource = FixedTime {
time: DateTime::parse_from_rfc3339(DEFAULT_TEST_TIME)
.expect("Invalid fixed time format")
.with_timezone(&Utc),
};
let redis = Arc::new(MockRedisClient::new());
let mut cfg = DEFAULT_CONFIG.clone();
cfg.capture_mode = CaptureMode::Events;
let quota_limiter =
CaptureQuotaLimiter::new(&cfg, redis.clone(), Duration::from_secs(60 * 60 * 24 * 7));
let service = EventRestrictionService::new(
Pipeline::for_capture_mode(CaptureMode::Events),
Duration::from_secs(300),
);
let mut manager = RestrictionManager::new();
manager.insert_restrictions(
restriction_pipeline,
token,
vec![Restriction {
restriction_type,
scope: RestrictionScope::AllEvents,
args: None,
}],
);
service.update(manager).await;
let router = router(
timesource,
readiness,
liveness,
Arc::new(sink),
redis,
None, // global_rate_limiter_token_distinctid
quota_limiter,
TokenDropper::default(),
Some(service),
None, // recorder_handle
CaptureMode::Events,
None,
25 * 1024 * 1024,
false,
1_i64,
false,
0.0_f32,
26_214_400,
None, // no blob storage for analytics
None,
256, // body_read_chunk_size_kb
None, // team_resolver (gate disabled: this suite does not exercise it)
Arc::new(capture::warnings::Warnings::default()),
10 * 1024 * 1024, // capture_v1_max_compressed_body_bytes
50 * 1024 * 1024, // capture_v1_max_decompressed_body_bytes
None, // overflow_limiter
None, // ai_events_overflow_limiter
None, // replay_overflow_limiter
None, // v1_sink_router
8, // capture_v1_scatter_gather_min_batch
None, // ai_gateway_signing_secret
ai_routing,
ai_events_overflow_enabled,
None, // ingestion_warning_emitter
);
(router, sink_clone)
}
struct ExpectedEvent<'a> {
// CapturedEvent fields
token: &'a str,
distinct_id: &'a str,
event_name: &'a str,
// ProcessedEventMetadata fields
data_type: DataType,
force_overflow: bool,
skip_person_processing: bool,
redirect_to_dlq: bool,
redirect_to_topic: Option<String>,
// Properties to verify in the event data
expected_properties: Option<Value>,
}
fn assert_event(event: &ProcessedEvent, expected: &ExpectedEvent) {
// Assert CapturedEvent fields
assert_eq!(event.event.token, expected.token, "token mismatch");
assert_eq!(
event.event.distinct_id, expected.distinct_id,
"distinct_id mismatch"
);
assert_eq!(
event.event.event, expected.event_name,
"event name mismatch"
);
assert!(!event.event.ip.is_empty(), "ip should not be empty");
assert!(!event.event.now.is_empty(), "now should not be empty");
assert!(!event.event.data.is_empty(), "data should not be empty");
// Assert ProcessedEventMetadata fields
assert_eq!(
event.metadata.data_type, expected.data_type,
"data_type mismatch"
);
assert_eq!(
event.metadata.event_name, expected.event_name,
"metadata.event_name mismatch"
);
assert_eq!(
event.metadata.force_overflow, expected.force_overflow,
"force_overflow mismatch"
);
assert_eq!(
event.metadata.skip_person_processing, expected.skip_person_processing,
"skip_person_processing mismatch"
);
assert_eq!(
event.metadata.redirect_to_dlq, expected.redirect_to_dlq,
"redirect_to_dlq mismatch"
);
assert_eq!(
event.metadata.redirect_to_topic, expected.redirect_to_topic,
"redirect_to_topic mismatch"
);
// Assert properties in event data
if let Some(expected_props) = &expected.expected_properties {
let data: Value =
serde_json::from_str(&event.event.data).expect("event.data should be valid JSON");
let actual_props = data
.get("properties")
.expect("event data should have properties");
for (key, expected_value) in expected_props.as_object().unwrap() {
let actual_value = actual_props.get(key).unwrap_or(&Value::Null);
assert_eq!(actual_value, expected_value, "property '{key}' mismatch");
}
}
}
#[tokio::test]
async fn test_analytics_drop_event_restriction() {
let restricted_token = "pk-restricted_drop_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::DropEvent,
Pipeline::Analytics,
restricted_token,
AiRouting::Primary,
false,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert!(
events.is_empty(),
"Event should be dropped by restriction, but {} events were published",
events.len()
);
}
#[tokio::test]
async fn test_analytics_redirect_to_dlq_restriction() {
let restricted_token = "pk-restricted_dlq_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::RedirectToDlq,
Pipeline::Analytics,
restricted_token,
AiRouting::Primary,
false,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(events.len(), 1);
assert_event(
&events[0],
&ExpectedEvent {
token: restricted_token,
distinct_id: "test_user",
event_name: "$pageview",
data_type: DataType::AnalyticsMain,
force_overflow: false,
skip_person_processing: false,
redirect_to_dlq: true,
redirect_to_topic: None,
expected_properties: None,
},
);
}
#[tokio::test]
async fn test_analytics_force_overflow_restriction() {
let restricted_token = "pk-restricted_overflow_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::ForceOverflow,
Pipeline::Analytics,
restricted_token,
AiRouting::Primary,
false,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(events.len(), 1);
assert_event(
&events[0],
&ExpectedEvent {
token: restricted_token,
distinct_id: "test_user",
event_name: "$pageview",
data_type: DataType::AnalyticsMain,
force_overflow: true,
skip_person_processing: false,
redirect_to_dlq: false,
redirect_to_topic: None,
expected_properties: None,
},
);
}
#[tokio::test]
async fn test_analytics_force_overflow_restriction_applies_to_diverted_ai_event() {
// A $ai_* event diverted onto the AI lane is governed by ai-scoped
// restrictions — the same Pipeline::Ai slice the dedicated AI endpoints
// consult — so this test inserts its ForceOverflow under Pipeline::Ai.
//
// The restriction must follow the event onto the lane: with secondary
// routing and the overflow valve armed, the event keeps
// DataType::AiEvents and carries force_overflow (the sink maps that pair
// to the AI overflow topic, never the analytics one). Catches the
// restriction pipeline or the router dropping restrictions for diverted
// events, which the process-level tests can't see end-to-end.
let restricted_token = "pk-restricted_overflow_ai_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::ForceOverflow,
Pipeline::Ai,
restricted_token,
AiRouting::Secondary,
true,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$ai_generation",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(events.len(), 1);
assert_event(
&events[0],
&ExpectedEvent {
token: restricted_token,
distinct_id: "test_user",
event_name: "$ai_generation",
data_type: DataType::AiEvents,
force_overflow: true,
skip_person_processing: false,
redirect_to_dlq: false,
redirect_to_topic: None,
expected_properties: None,
},
);
assert_eq!(
events[0].metadata.overflow_reason, None,
"force_overflow short-circuits stamping on the AI lane exactly like analytics"
);
}
#[tokio::test]
async fn test_analytics_skip_person_processing_restriction() {
let restricted_token = "pk-restricted_skip_person_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::SkipPersonProcessing,
Pipeline::Analytics,
restricted_token,
AiRouting::Primary,
false,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(events.len(), 1);
assert_event(
&events[0],
&ExpectedEvent {
token: restricted_token,
distinct_id: "test_user",
event_name: "$pageview",
data_type: DataType::AnalyticsMain,
force_overflow: false,
skip_person_processing: true,
redirect_to_dlq: false,
redirect_to_topic: None,
expected_properties: None,
},
);
}
#[tokio::test]
async fn test_analytics_restriction_does_not_apply_to_other_tokens() {
let restricted_token = "pk-restricted_token";
let (router, sink) = setup_analytics_router_with_restriction(
RestrictionType::DropEvent,
Pipeline::Analytics,
restricted_token,
AiRouting::Primary,
false,
)
.await;
let test_client = TestClient::new(router);
let payload = json!({
"token": "pk-not_restricted_token",
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(
events.len(),
1,
"Event should be published for non-restricted token"
);
assert_event(
&events[0],
&ExpectedEvent {
token: "pk-not_restricted_token",
distinct_id: "test_user",
event_name: "$pageview",
data_type: DataType::AnalyticsMain,
force_overflow: false,
skip_person_processing: false,
redirect_to_dlq: false,
redirect_to_topic: None,
expected_properties: None,
},
);
}
async fn setup_analytics_router_with_redirect_to_topic(
token: &str,
topic: &str,
) -> (Router, CapturingSink) {
let (readiness, liveness, _monitor) = test_lifecycle_handlers();
let sink = CapturingSink::new();
let sink_clone = sink.clone();
let timesource = FixedTime {
time: DateTime::parse_from_rfc3339(DEFAULT_TEST_TIME)
.expect("Invalid fixed time format")
.with_timezone(&Utc),
};
let redis = Arc::new(MockRedisClient::new());
let mut cfg = DEFAULT_CONFIG.clone();
cfg.capture_mode = CaptureMode::Events;
let quota_limiter =
CaptureQuotaLimiter::new(&cfg, redis.clone(), Duration::from_secs(60 * 60 * 24 * 7));
let service = EventRestrictionService::new(vec![Pipeline::Analytics], Duration::from_secs(300));
let mut manager = RestrictionManager::new();
manager.insert_restrictions(
Pipeline::Analytics,
token,
vec![Restriction {
restriction_type: RestrictionType::RedirectToTopic,
scope: RestrictionScope::AllEvents,
args: Some(json!({"topic": topic})),
}],
);
service.update(manager).await;
let router = router(
timesource,
readiness,
liveness,
Arc::new(sink),
redis,
None, // global_rate_limiter_token_distinctid
quota_limiter,
TokenDropper::default(),
Some(service),
None, // recorder_handle
CaptureMode::Events,
None,
25 * 1024 * 1024,
false,
1_i64,
false,
0.0_f32,
26_214_400,
None,
None,
256, // body_read_chunk_size_kb
10 * 1024 * 1024, // capture_v1_max_compressed_body_bytes
50 * 1024 * 1024, // capture_v1_max_decompressed_body_bytes
None, // overflow_limiter
None, // ai_events_overflow_limiter
None, // replay_overflow_limiter
None, // v1_sink_router
8, // capture_v1_scatter_gather_min_batch
None, // ai_gateway_signing_secret
AiRouting::Primary, // ai_routing
false, // ai_events_overflow_enabled
None, // ingestion_warning_emitter
);
(router, sink_clone)
}
#[tokio::test]
async fn test_analytics_redirect_to_topic_restriction() {
let restricted_token = "pk-restricted_redirect_topic_token";
let target_topic = "custom_events_topic";
let (router, sink) =
setup_analytics_router_with_redirect_to_topic(restricted_token, target_topic).await;
let test_client = TestClient::new(router);
let payload = json!({
"token": restricted_token,
"event": "$pageview",
"distinct_id": "test_user"
});
let response = test_client
.post("/capture")
.header("Content-Type", "application/json")
.header("X-Forwarded-For", "127.0.0.1")
.body(payload.to_string())
.send()
.await;
assert_eq!(response.status(), StatusCode::OK);
let events = sink.get_events().await;
assert_eq!(events.len(), 1);
assert_event(
&events[0],
&ExpectedEvent {
token: restricted_token,
distinct_id: "test_user",
event_name: "$pageview",
data_type: DataType::AnalyticsMain,
force_overflow: false,
skip_person_processing: false,
redirect_to_dlq: false,
redirect_to_topic: Some(target_topic.to_string()),
expected_properties: None,
},
);
}