Skip to main content

postrust_proxy/saas/
db.rs

1//! Database access layer for the SaaS domain management module.
2//!
3//! All queries run against the schema defined in
4//! `migrations/20240115000001_saas_domains.sql`. Queries use runtime sqlx
5//! (`query`/`query_as`) rather than the compile-time-checked macros so the
6//! crate builds without a live database connection.
7
8use crate::error::ProxyResult;
9use crate::saas::types::*;
10use chrono::{DateTime, Utc};
11use sqlx::{PgPool, Row};
12use uuid::Uuid;
13
14// ============================================================================
15// Enum <-> string helpers
16//
17// The enum columns are plain VARCHAR with CHECK constraints, so we bind their
18// canonical string form directly and let the `From<*Row>` impls decode back.
19// ============================================================================
20
21fn verification_status_str(status: &VerificationStatus) -> &'static str {
22    match status {
23        VerificationStatus::Pending => "pending",
24        VerificationStatus::Verified => "verified",
25        VerificationStatus::Failed => "failed",
26        VerificationStatus::Expired => "expired",
27    }
28}
29
30fn verification_method_str(method: &VerificationMethod) -> &'static str {
31    match method {
32        VerificationMethod::Dns => "dns",
33        VerificationMethod::Http => "http",
34    }
35}
36
37fn ssl_status_str(status: &SslStatus) -> &'static str {
38    match status {
39        SslStatus::Pending => "pending",
40        SslStatus::Provisioning => "provisioning",
41        SslStatus::Active => "active",
42        SslStatus::Failed => "failed",
43        SslStatus::Expired => "expired",
44    }
45}
46
47fn ssl_provider_str(provider: &SslProvider) -> &'static str {
48    match provider {
49        SslProvider::Acme => "acme",
50        SslProvider::Manual => "manual",
51        SslProvider::None => "none",
52    }
53}
54
55fn path_type_str(path_type: &DomainPathMatchType) -> &'static str {
56    match path_type {
57        DomainPathMatchType::Prefix => "prefix",
58        DomainPathMatchType::Exact => "exact",
59        DomainPathMatchType::Regex => "regex",
60    }
61}
62
63fn lb_strategy_str(strategy: &DomainLoadBalanceStrategy) -> &'static str {
64    match strategy {
65        DomainLoadBalanceStrategy::RoundRobin => "round_robin",
66        DomainLoadBalanceStrategy::LeastConnections => "least_connections",
67        DomainLoadBalanceStrategy::Weighted => "weighted",
68        DomainLoadBalanceStrategy::Random => "random",
69        DomainLoadBalanceStrategy::Sticky => "sticky",
70    }
71}
72
73// ============================================================================
74// Domains
75// ============================================================================
76
77/// Return `(current_domain_count, max_domains)` for a tenant.
78pub async fn check_domain_quota(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<(i64, i32)> {
79    let row = sqlx::query(
80        "SELECT \
81            (SELECT COUNT(*) FROM proxy_domains WHERE tenant_id = $1) AS current, \
82            COALESCE((SELECT max_domains FROM proxy_tenants WHERE id = $1), 0) AS max",
83    )
84    .bind(tenant_id)
85    .fetch_one(pool)
86    .await?;
87
88    Ok((row.get::<i64, _>("current"), row.get::<i32, _>("max")))
89}
90
91/// Check whether a domain is already registered (globally unique).
92pub async fn domain_exists(pool: &PgPool, domain: &str) -> ProxyResult<bool> {
93    let row =
94        sqlx::query("SELECT EXISTS(SELECT 1 FROM proxy_domains WHERE domain = $1) AS present")
95            .bind(domain)
96            .fetch_one(pool)
97            .await?;
98    Ok(row.get::<bool, _>("present"))
99}
100
101/// Insert a new domain and return it.
102pub async fn create_domain(
103    pool: &PgPool,
104    tenant_id: Uuid,
105    req: CreateDomainRequest,
106    verification_token: &str,
107) -> ProxyResult<Domain> {
108    let row = sqlx::query_as::<_, DomainRow>(
109        "INSERT INTO proxy_domains \
110            (tenant_id, domain, verification_method, verification_token, ssl_provider) \
111         VALUES ($1, $2, $3, $4, $5) \
112         RETURNING *",
113    )
114    .bind(tenant_id)
115    .bind(&req.domain)
116    .bind(verification_method_str(&req.verification_method))
117    .bind(verification_token)
118    .bind(ssl_provider_str(&req.ssl_provider))
119    .fetch_one(pool)
120    .await?;
121
122    Ok(Domain::from(row))
123}
124
125/// Record a verification challenge for a domain.
126pub async fn create_verification_challenge(
127    pool: &PgPool,
128    domain_id: Uuid,
129    challenge_type: &str,
130    token: &str,
131    expected_value: &str,
132) -> ProxyResult<()> {
133    sqlx::query(
134        "INSERT INTO proxy_verification_challenges \
135            (domain_id, challenge_type, token, expected_value) \
136         VALUES ($1, $2, $3, $4)",
137    )
138    .bind(domain_id)
139    .bind(challenge_type)
140    .bind(token)
141    .bind(expected_value)
142    .execute(pool)
143    .await?;
144    Ok(())
145}
146
147/// Fetch a domain scoped to a tenant.
148pub async fn get_domain_for_tenant(
149    pool: &PgPool,
150    id: Uuid,
151    tenant_id: Uuid,
152) -> ProxyResult<Option<Domain>> {
153    let row = sqlx::query_as::<_, DomainRow>(
154        "SELECT * FROM proxy_domains WHERE id = $1 AND tenant_id = $2",
155    )
156    .bind(id)
157    .bind(tenant_id)
158    .fetch_optional(pool)
159    .await?;
160    Ok(row.map(Domain::from))
161}
162
163/// List all domains for a tenant.
164pub async fn list_domains(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<Vec<Domain>> {
165    let rows = sqlx::query_as::<_, DomainRow>(
166        "SELECT * FROM proxy_domains WHERE tenant_id = $1 ORDER BY created_at DESC",
167    )
168    .bind(tenant_id)
169    .fetch_all(pool)
170    .await?;
171    Ok(rows.into_iter().map(Domain::from).collect())
172}
173
174/// Delete a domain scoped to a tenant. Returns whether a row was removed.
175pub async fn delete_domain(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
176    let result = sqlx::query("DELETE FROM proxy_domains WHERE id = $1 AND tenant_id = $2")
177        .bind(id)
178        .bind(tenant_id)
179        .execute(pool)
180        .await?;
181    Ok(result.rows_affected() > 0)
182}
183
184/// Increment the verification attempt counter and stamp the attempt time.
185pub async fn record_verification_attempt(pool: &PgPool, id: Uuid) -> ProxyResult<()> {
186    sqlx::query(
187        "UPDATE proxy_domains \
188         SET verification_attempts = verification_attempts + 1, \
189             last_verification_attempt = NOW(), \
190             updated_at = NOW() \
191         WHERE id = $1",
192    )
193    .bind(id)
194    .execute(pool)
195    .await?;
196    Ok(())
197}
198
199/// Update a domain's verification status (stamping `verified_at` on success).
200pub async fn update_verification_status(
201    pool: &PgPool,
202    id: Uuid,
203    status: VerificationStatus,
204) -> ProxyResult<()> {
205    let status_str = verification_status_str(&status);
206    sqlx::query(
207        "UPDATE proxy_domains \
208         SET verification_status = $2, \
209             verified_at = CASE WHEN $2 = 'verified' THEN NOW() ELSE verified_at END, \
210             updated_at = NOW() \
211         WHERE id = $1",
212    )
213    .bind(id)
214    .bind(status_str)
215    .execute(pool)
216    .await?;
217    Ok(())
218}
219
220/// Update a domain's SSL status and optional expiry.
221pub async fn update_ssl_status(
222    pool: &PgPool,
223    id: Uuid,
224    status: SslStatus,
225    expires_at: Option<DateTime<Utc>>,
226) -> ProxyResult<()> {
227    sqlx::query(
228        "UPDATE proxy_domains \
229         SET ssl_status = $2, ssl_expires_at = $3, updated_at = NOW() \
230         WHERE id = $1",
231    )
232    .bind(id)
233    .bind(ssl_status_str(&status))
234    .bind(expires_at)
235    .execute(pool)
236    .await?;
237    Ok(())
238}
239
240/// Enable a domain.
241pub async fn enable_domain(pool: &PgPool, id: Uuid) -> ProxyResult<bool> {
242    let result =
243        sqlx::query("UPDATE proxy_domains SET enabled = true, updated_at = NOW() WHERE id = $1")
244            .bind(id)
245            .execute(pool)
246            .await?;
247    Ok(result.rows_affected() > 0)
248}
249
250/// Disable a domain.
251pub async fn disable_domain(pool: &PgPool, id: Uuid) -> ProxyResult<bool> {
252    let result =
253        sqlx::query("UPDATE proxy_domains SET enabled = false, updated_at = NOW() WHERE id = $1")
254            .bind(id)
255            .execute(pool)
256            .await?;
257    Ok(result.rows_affected() > 0)
258}
259
260// ============================================================================
261// Routes
262// ============================================================================
263
264/// Create a route for a domain.
265pub async fn create_route(
266    pool: &PgPool,
267    domain_id: Uuid,
268    tenant_id: Uuid,
269    req: CreateDomainRouteRequest,
270) -> ProxyResult<DomainRoute> {
271    let add_headers = serde_json::to_value(&req.add_headers).unwrap_or_default();
272
273    let row = sqlx::query_as::<_, DomainRouteRow>(
274        "INSERT INTO proxy_domain_routes \
275            (domain_id, tenant_id, name, path_pattern, path_type, methods, priority, \
276             upstream_id, strip_path, add_headers, remove_headers, rate_limit_requests, \
277             rate_limit_window_secs, timeout_secs) \
278         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \
279         RETURNING *",
280    )
281    .bind(domain_id)
282    .bind(tenant_id)
283    .bind(&req.name)
284    .bind(&req.path_pattern)
285    .bind(path_type_str(&req.path_type))
286    .bind(&req.methods)
287    .bind(req.priority)
288    .bind(req.upstream_id)
289    .bind(req.strip_path)
290    .bind(add_headers)
291    .bind(&req.remove_headers)
292    .bind(req.rate_limit_requests)
293    .bind(req.rate_limit_window_secs)
294    .bind(req.timeout_secs)
295    .fetch_one(pool)
296    .await?;
297
298    Ok(DomainRoute::from(row))
299}
300
301/// Fetch a route scoped to a tenant.
302pub async fn get_route_for_tenant(
303    pool: &PgPool,
304    id: Uuid,
305    tenant_id: Uuid,
306) -> ProxyResult<Option<DomainRoute>> {
307    let row = sqlx::query_as::<_, DomainRouteRow>(
308        "SELECT * FROM proxy_domain_routes WHERE id = $1 AND tenant_id = $2",
309    )
310    .bind(id)
311    .bind(tenant_id)
312    .fetch_optional(pool)
313    .await?;
314    Ok(row.map(DomainRoute::from))
315}
316
317/// List routes for a domain, highest priority first.
318pub async fn list_routes_for_domain(
319    pool: &PgPool,
320    domain_id: Uuid,
321    tenant_id: Uuid,
322) -> ProxyResult<Vec<DomainRoute>> {
323    let rows = sqlx::query_as::<_, DomainRouteRow>(
324        "SELECT * FROM proxy_domain_routes \
325         WHERE domain_id = $1 AND tenant_id = $2 \
326         ORDER BY priority DESC, created_at",
327    )
328    .bind(domain_id)
329    .bind(tenant_id)
330    .fetch_all(pool)
331    .await?;
332    Ok(rows.into_iter().map(DomainRoute::from).collect())
333}
334
335/// Apply a partial update to a route (only supplied fields change).
336pub async fn update_route(
337    pool: &PgPool,
338    id: Uuid,
339    tenant_id: Uuid,
340    req: UpdateDomainRouteRequest,
341) -> ProxyResult<Option<DomainRoute>> {
342    let path_type = req.path_type.as_ref().map(path_type_str);
343    let add_headers = req
344        .add_headers
345        .as_ref()
346        .map(|h| serde_json::to_value(h).unwrap_or_default());
347
348    let row = sqlx::query_as::<_, DomainRouteRow>(
349        "UPDATE proxy_domain_routes SET \
350            name = COALESCE($3, name), \
351            path_pattern = COALESCE($4, path_pattern), \
352            path_type = COALESCE($5, path_type), \
353            methods = COALESCE($6, methods), \
354            upstream_id = COALESCE($7, upstream_id), \
355            strip_path = COALESCE($8, strip_path), \
356            priority = COALESCE($9, priority), \
357            add_headers = COALESCE($10, add_headers), \
358            remove_headers = COALESCE($11, remove_headers), \
359            rate_limit_requests = COALESCE($12, rate_limit_requests), \
360            rate_limit_window_secs = COALESCE($13, rate_limit_window_secs), \
361            timeout_secs = COALESCE($14, timeout_secs), \
362            enabled = COALESCE($15, enabled), \
363            updated_at = NOW() \
364         WHERE id = $1 AND tenant_id = $2 \
365         RETURNING *",
366    )
367    .bind(id)
368    .bind(tenant_id)
369    .bind(req.name)
370    .bind(req.path_pattern)
371    .bind(path_type)
372    .bind(req.methods)
373    .bind(req.upstream_id)
374    .bind(req.strip_path)
375    .bind(req.priority)
376    .bind(add_headers)
377    .bind(req.remove_headers)
378    .bind(req.rate_limit_requests)
379    .bind(req.rate_limit_window_secs)
380    .bind(req.timeout_secs)
381    .bind(req.enabled)
382    .fetch_optional(pool)
383    .await?;
384
385    Ok(row.map(DomainRoute::from))
386}
387
388/// Delete a route scoped to a tenant.
389pub async fn delete_route(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
390    let result = sqlx::query("DELETE FROM proxy_domain_routes WHERE id = $1 AND tenant_id = $2")
391        .bind(id)
392        .bind(tenant_id)
393        .execute(pool)
394        .await?;
395    Ok(result.rows_affected() > 0)
396}
397
398// ============================================================================
399// Upstreams & backends
400// ============================================================================
401
402/// Load the backends belonging to an upstream.
403async fn load_backends(pool: &PgPool, upstream_id: Uuid) -> ProxyResult<Vec<DomainBackend>> {
404    let backends = sqlx::query_as::<_, DomainBackend>(
405        "SELECT * FROM proxy_domain_backends WHERE upstream_id = $1 ORDER BY created_at",
406    )
407    .bind(upstream_id)
408    .fetch_all(pool)
409    .await?;
410    Ok(backends)
411}
412
413/// Create an upstream (and any backends supplied inline).
414pub async fn create_upstream(
415    pool: &PgPool,
416    tenant_id: Uuid,
417    req: CreateUpstreamRequest,
418) -> ProxyResult<DomainUpstream> {
419    let row = sqlx::query_as::<_, DomainUpstreamRow>(
420        "INSERT INTO proxy_domain_upstreams \
421            (tenant_id, name, lb_strategy, health_check_enabled, health_check_path, \
422             health_check_interval_secs, health_check_timeout_secs, healthy_threshold, \
423             unhealthy_threshold) \
424         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
425         RETURNING *",
426    )
427    .bind(tenant_id)
428    .bind(&req.name)
429    .bind(lb_strategy_str(&req.lb_strategy))
430    .bind(req.health_check_enabled)
431    .bind(&req.health_check_path)
432    .bind(req.health_check_interval_secs)
433    .bind(req.health_check_timeout_secs)
434    .bind(req.healthy_threshold)
435    .bind(req.unhealthy_threshold)
436    .fetch_one(pool)
437    .await?;
438
439    let mut upstream = DomainUpstream::from(row);
440
441    for backend in req.backends {
442        create_backend(pool, upstream.id, backend).await?;
443    }
444    upstream.backends = load_backends(pool, upstream.id).await?;
445
446    Ok(upstream)
447}
448
449/// Fetch an upstream (with its backends) scoped to a tenant.
450pub async fn get_upstream_for_tenant(
451    pool: &PgPool,
452    id: Uuid,
453    tenant_id: Uuid,
454) -> ProxyResult<Option<DomainUpstream>> {
455    let row = sqlx::query_as::<_, DomainUpstreamRow>(
456        "SELECT * FROM proxy_domain_upstreams WHERE id = $1 AND tenant_id = $2",
457    )
458    .bind(id)
459    .bind(tenant_id)
460    .fetch_optional(pool)
461    .await?;
462
463    match row {
464        Some(row) => {
465            let mut upstream = DomainUpstream::from(row);
466            upstream.backends = load_backends(pool, upstream.id).await?;
467            Ok(Some(upstream))
468        }
469        None => Ok(None),
470    }
471}
472
473/// List upstreams (with backends) for a tenant.
474pub async fn list_upstreams(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<Vec<DomainUpstream>> {
475    let rows = sqlx::query_as::<_, DomainUpstreamRow>(
476        "SELECT * FROM proxy_domain_upstreams WHERE tenant_id = $1 ORDER BY created_at",
477    )
478    .bind(tenant_id)
479    .fetch_all(pool)
480    .await?;
481
482    let mut upstreams = Vec::with_capacity(rows.len());
483    for row in rows {
484        let mut upstream = DomainUpstream::from(row);
485        upstream.backends = load_backends(pool, upstream.id).await?;
486        upstreams.push(upstream);
487    }
488    Ok(upstreams)
489}
490
491/// Apply a partial update to an upstream.
492pub async fn update_upstream(
493    pool: &PgPool,
494    id: Uuid,
495    tenant_id: Uuid,
496    req: UpdateUpstreamRequest,
497) -> ProxyResult<Option<DomainUpstream>> {
498    let lb_strategy = req.lb_strategy.as_ref().map(lb_strategy_str);
499
500    let row = sqlx::query_as::<_, DomainUpstreamRow>(
501        "UPDATE proxy_domain_upstreams SET \
502            name = COALESCE($3, name), \
503            lb_strategy = COALESCE($4, lb_strategy), \
504            health_check_enabled = COALESCE($5, health_check_enabled), \
505            health_check_path = COALESCE($6, health_check_path), \
506            health_check_interval_secs = COALESCE($7, health_check_interval_secs), \
507            health_check_timeout_secs = COALESCE($8, health_check_timeout_secs), \
508            healthy_threshold = COALESCE($9, healthy_threshold), \
509            unhealthy_threshold = COALESCE($10, unhealthy_threshold), \
510            enabled = COALESCE($11, enabled), \
511            updated_at = NOW() \
512         WHERE id = $1 AND tenant_id = $2 \
513         RETURNING *",
514    )
515    .bind(id)
516    .bind(tenant_id)
517    .bind(req.name)
518    .bind(lb_strategy)
519    .bind(req.health_check_enabled)
520    .bind(req.health_check_path)
521    .bind(req.health_check_interval_secs)
522    .bind(req.health_check_timeout_secs)
523    .bind(req.healthy_threshold)
524    .bind(req.unhealthy_threshold)
525    .bind(req.enabled)
526    .fetch_optional(pool)
527    .await?;
528
529    match row {
530        Some(row) => {
531            let mut upstream = DomainUpstream::from(row);
532            upstream.backends = load_backends(pool, upstream.id).await?;
533            Ok(Some(upstream))
534        }
535        None => Ok(None),
536    }
537}
538
539/// Delete an upstream scoped to a tenant.
540pub async fn delete_upstream(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
541    let result = sqlx::query("DELETE FROM proxy_domain_upstreams WHERE id = $1 AND tenant_id = $2")
542        .bind(id)
543        .bind(tenant_id)
544        .execute(pool)
545        .await?;
546    Ok(result.rows_affected() > 0)
547}
548
549/// Add a backend to an upstream.
550pub async fn create_backend(
551    pool: &PgPool,
552    upstream_id: Uuid,
553    req: CreateBackendRequest,
554) -> ProxyResult<DomainBackend> {
555    let backend = sqlx::query_as::<_, DomainBackend>(
556        "INSERT INTO proxy_domain_backends (upstream_id, address, scheme, weight) \
557         VALUES ($1, $2, $3, $4) \
558         RETURNING *",
559    )
560    .bind(upstream_id)
561    .bind(&req.address)
562    .bind(&req.scheme)
563    .bind(req.weight)
564    .fetch_one(pool)
565    .await?;
566    Ok(backend)
567}
568
569/// Delete a backend, verifying it belongs to the tenant's upstream.
570pub async fn delete_backend(
571    pool: &PgPool,
572    backend_id: Uuid,
573    upstream_id: Uuid,
574    tenant_id: Uuid,
575) -> ProxyResult<bool> {
576    let result = sqlx::query(
577        "DELETE FROM proxy_domain_backends \
578         WHERE id = $1 AND upstream_id = $2 \
579           AND upstream_id IN (SELECT id FROM proxy_domain_upstreams WHERE id = $2 AND tenant_id = $3)",
580    )
581    .bind(backend_id)
582    .bind(upstream_id)
583    .bind(tenant_id)
584    .execute(pool)
585    .await?;
586    Ok(result.rows_affected() > 0)
587}
588
589// ============================================================================
590// API keys
591// ============================================================================
592
593/// Insert a new API key row (the raw key is never stored, only its hash).
594pub async fn create_api_key(
595    pool: &PgPool,
596    tenant_id: Uuid,
597    req: CreateApiKeyRequest,
598    key_hash: &str,
599    key_prefix: &str,
600) -> ProxyResult<ApiKeyRow> {
601    let row = sqlx::query_as::<_, ApiKeyRow>(
602        "INSERT INTO proxy_api_keys (tenant_id, name, key_hash, key_prefix, scopes, expires_at) \
603         VALUES ($1, $2, $3, $4, $5, $6) \
604         RETURNING *",
605    )
606    .bind(tenant_id)
607    .bind(&req.name)
608    .bind(key_hash)
609    .bind(key_prefix)
610    .bind(&req.scopes)
611    .bind(req.expires_at)
612    .fetch_one(pool)
613    .await?;
614    Ok(row)
615}
616
617/// Look up an API key by its hash, excluding expired keys.
618pub async fn validate_api_key_by_hash(
619    pool: &PgPool,
620    key_hash: &str,
621) -> ProxyResult<Option<ApiKeyRow>> {
622    let row = sqlx::query_as::<_, ApiKeyRow>(
623        "SELECT * FROM proxy_api_keys \
624         WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())",
625    )
626    .bind(key_hash)
627    .fetch_optional(pool)
628    .await?;
629    Ok(row)
630}
631
632/// Stamp an API key's last-used time (best-effort).
633pub async fn update_last_used(pool: &PgPool, key_id: Uuid) -> ProxyResult<()> {
634    sqlx::query("UPDATE proxy_api_keys SET last_used_at = NOW() WHERE id = $1")
635        .bind(key_id)
636        .execute(pool)
637        .await?;
638    Ok(())
639}
640
641/// List API keys for a tenant.
642pub async fn list_api_keys(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<Vec<ApiKey>> {
643    let rows = sqlx::query_as::<_, ApiKeyRow>(
644        "SELECT * FROM proxy_api_keys WHERE tenant_id = $1 ORDER BY created_at DESC",
645    )
646    .bind(tenant_id)
647    .fetch_all(pool)
648    .await?;
649    Ok(rows.into_iter().map(ApiKey::from).collect())
650}
651
652/// Fetch an API key scoped to a tenant.
653pub async fn get_api_key_for_tenant(
654    pool: &PgPool,
655    id: Uuid,
656    tenant_id: Uuid,
657) -> ProxyResult<Option<ApiKey>> {
658    let row = sqlx::query_as::<_, ApiKeyRow>(
659        "SELECT * FROM proxy_api_keys WHERE id = $1 AND tenant_id = $2",
660    )
661    .bind(id)
662    .bind(tenant_id)
663    .fetch_optional(pool)
664    .await?;
665    Ok(row.map(ApiKey::from))
666}
667
668/// Delete an API key scoped to a tenant.
669pub async fn delete_api_key(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
670    let result = sqlx::query("DELETE FROM proxy_api_keys WHERE id = $1 AND tenant_id = $2")
671        .bind(id)
672        .bind(tenant_id)
673        .execute(pool)
674        .await?;
675    Ok(result.rows_affected() > 0)
676}
677
678/// Disable an API key without deleting it.
679pub async fn disable_api_key(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
680    let result =
681        sqlx::query("UPDATE proxy_api_keys SET enabled = false WHERE id = $1 AND tenant_id = $2")
682            .bind(id)
683            .bind(tenant_id)
684            .execute(pool)
685            .await?;
686    Ok(result.rows_affected() > 0)
687}
688
689/// Re-enable a disabled API key.
690pub async fn enable_api_key(pool: &PgPool, id: Uuid, tenant_id: Uuid) -> ProxyResult<bool> {
691    let result =
692        sqlx::query("UPDATE proxy_api_keys SET enabled = true WHERE id = $1 AND tenant_id = $2")
693            .bind(id)
694            .bind(tenant_id)
695            .execute(pool)
696            .await?;
697    Ok(result.rows_affected() > 0)
698}
699
700// ============================================================================
701// Tenants
702// ============================================================================
703
704/// Whether a tenant exists and is active.
705pub async fn is_tenant_active(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<bool> {
706    let row = sqlx::query(
707        "SELECT EXISTS(SELECT 1 FROM proxy_tenants WHERE id = $1 AND status = 'active') AS active",
708    )
709    .bind(tenant_id)
710    .fetch_one(pool)
711    .await?;
712    Ok(row.get::<bool, _>("active"))
713}
714
715/// Aggregate usage statistics for a tenant.
716pub async fn get_tenant_usage(pool: &PgPool, tenant_id: Uuid) -> ProxyResult<TenantUsage> {
717    let row = sqlx::query(
718        "SELECT \
719            (SELECT COUNT(*) FROM proxy_domains WHERE tenant_id = $1) AS domains_count, \
720            COALESCE((SELECT max_domains FROM proxy_tenants WHERE id = $1), 0) AS domains_limit, \
721            (SELECT COUNT(*) FROM proxy_domains WHERE tenant_id = $1 AND verification_status = 'verified') AS verified_domains, \
722            (SELECT COUNT(*) FROM proxy_domain_routes WHERE tenant_id = $1) AS routes_count, \
723            (SELECT COUNT(*) FROM proxy_domain_upstreams WHERE tenant_id = $1) AS upstreams_count, \
724            (SELECT COUNT(*) FROM proxy_api_keys WHERE tenant_id = $1) AS api_keys_count",
725    )
726    .bind(tenant_id)
727    .fetch_one(pool)
728    .await?;
729
730    Ok(TenantUsage {
731        domains_count: row.get::<i64, _>("domains_count"),
732        domains_limit: row.get::<i32, _>("domains_limit"),
733        verified_domains: row.get::<i64, _>("verified_domains"),
734        routes_count: row.get::<i64, _>("routes_count"),
735        upstreams_count: row.get::<i64, _>("upstreams_count"),
736        api_keys_count: row.get::<i64, _>("api_keys_count"),
737    })
738}