diff --git a/apps/payments/src/ai_credits.rs b/apps/payments/src/ai_credits.rs index 9d8e8a9..ea4d188 100644 --- a/apps/payments/src/ai_credits.rs +++ b/apps/payments/src/ai_credits.rs @@ -11,8 +11,8 @@ use crate::payu; use crate::AppState; use axum::{ - extract::{Path, State}, - http::StatusCode, + extract::{FromRequestParts, Path, State}, + http::{request::Parts, StatusCode}, routing::{get, patch, post}, Json, Router, }; @@ -22,6 +22,20 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; +/// AuthUser doesn't implement axum's optional-extraction trait, so a bare +/// `Option` handler param won't compile - this wraps it manually. +/// Used by list_packages, which needs to know the viewer's role when logged +/// in but must still work for anonymous/logged-out browsing. +struct MaybeAuthUser(Option); + +impl FromRequestParts for MaybeAuthUser { + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + Ok(MaybeAuthUser(AuthUser::from_request_parts(parts, state).await.ok())) + } +} + pub fn router() -> Router { Router::new() .route("/", get(list_packages)) @@ -47,6 +61,7 @@ struct AiCreditPackageRow { description: Option, credits: i32, price_inr: i32, + applicable_roles: Vec, } #[derive(Serialize)] @@ -54,14 +69,36 @@ struct ListPackagesResponse { packages: Vec, } -async fn list_packages(State(state): State) -> Result, (StatusCode, String)> { +/// A package with an empty applicable_roles is visible to everyone (the +/// default, matching pre-role-scoping behavior); otherwise the viewer must +/// hold at least one of the listed roles. Logged-out/anonymous viewers only +/// ever see role-unrestricted packages. +fn package_visible_to(applicable_roles: &[String], viewer_roles: Option<&[String]>) -> bool { + if applicable_roles.is_empty() { + return true; + } + viewer_roles + .map(|roles| roles.iter().any(|r| applicable_roles.iter().any(|ar| ar.eq_ignore_ascii_case(r)))) + .unwrap_or(false) +} + +async fn list_packages( + MaybeAuthUser(auth): MaybeAuthUser, + State(state): State, +) -> Result, (StatusCode, String)> { let packages = sqlx::query_as::<_, AiCreditPackageRow>( - "SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE is_active = TRUE ORDER BY price_inr", + "SELECT id, name, description, credits, price_inr, applicable_roles FROM ai_credit_packages WHERE is_active = TRUE ORDER BY price_inr", ) .fetch_all(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + let viewer_roles = auth.as_ref().map(|a| a.claims.roles.as_slice()); + let packages = packages + .into_iter() + .filter(|p| package_visible_to(&p.applicable_roles, viewer_roles)) + .collect(); + Ok(Json(ListPackagesResponse { packages })) } @@ -95,6 +132,7 @@ struct AiCreditPackagePriceRow { name: String, credits: i32, price_inr: i32, + applicable_roles: Vec, } #[derive(Debug, FromRow)] @@ -191,7 +229,7 @@ async fn create_order( .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?; let package = sqlx::query_as::<_, AiCreditPackagePriceRow>( - "SELECT name, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = TRUE", + "SELECT name, credits, price_inr, applicable_roles FROM ai_credit_packages WHERE id = $1 AND is_active = TRUE", ) .bind(package_id) .fetch_optional(&state.pool) @@ -199,6 +237,12 @@ async fn create_order( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? .ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive AI credit package".to_string()))?; + // Enforce role scoping server-side too, not just in the listing - a + // logged-in user could otherwise buy a package never shown to them. + if !package_visible_to(&package.applicable_roles, Some(auth.claims.roles.as_slice())) { + return Err((StatusCode::FORBIDDEN, "This package is not available for your role".to_string())); + } + let contact = sqlx::query_as::<_, UserContactRow>("SELECT email, full_name, phone FROM users WHERE id = $1") .bind(auth.user_id) .fetch_optional(&state.pool) @@ -433,6 +477,7 @@ struct AdminAiCreditPackageRow { credits: i32, price_inr: i32, is_active: bool, + applicable_roles: Vec, created_at: chrono::DateTime, updated_at: chrono::DateTime, } @@ -449,7 +494,7 @@ async fn admin_list_packages( require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let packages = sqlx::query_as::<_, AdminAiCreditPackageRow>( - "SELECT id, name, description, credits, price_inr, is_active, created_at, updated_at FROM ai_credit_packages ORDER BY price_inr", + "SELECT id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at FROM ai_credit_packages ORDER BY price_inr", ) .fetch_all(&state.pool) .await @@ -464,6 +509,8 @@ struct CreatePackageRequest { description: Option, credits: i32, price_inr: i32, + #[serde(default)] + applicable_roles: Vec, } async fn admin_create_package( @@ -479,15 +526,16 @@ async fn admin_create_package( let package = sqlx::query_as::<_, AdminAiCreditPackageRow>( r#" - INSERT INTO ai_credit_packages (name, description, credits, price_inr) - VALUES ($1, $2, $3, $4) - RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at + INSERT INTO ai_credit_packages (name, description, credits, price_inr, applicable_roles) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at "#, ) .bind(&body.name) .bind(&body.description) .bind(body.credits) .bind(body.price_inr) + .bind(&body.applicable_roles) .fetch_one(&state.pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; @@ -502,6 +550,7 @@ struct UpdatePackageRequest { credits: Option, price_inr: Option, is_active: Option, + applicable_roles: Option>, } async fn admin_update_package( @@ -524,9 +573,10 @@ async fn admin_update_package( credits = COALESCE($3, credits), price_inr = COALESCE($4, price_inr), is_active = COALESCE($5, is_active), + applicable_roles = COALESCE($6, applicable_roles), updated_at = NOW() - WHERE id = $6 - RETURNING id, name, description, credits, price_inr, is_active, created_at, updated_at + WHERE id = $7 + RETURNING id, name, description, credits, price_inr, is_active, applicable_roles, created_at, updated_at "#, ) .bind(&body.name) @@ -534,6 +584,7 @@ async fn admin_update_package( .bind(body.credits) .bind(body.price_inr) .bind(body.is_active) + .bind(&body.applicable_roles) .bind(id) .fetch_optional(&state.pool) .await diff --git a/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.down.sql b/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.down.sql new file mode 100644 index 0000000..ab34c24 --- /dev/null +++ b/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_ai_credit_packages_applicable_roles; +ALTER TABLE ai_credit_packages DROP COLUMN IF EXISTS applicable_roles; + +COMMIT; diff --git a/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.up.sql b/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.up.sql new file mode 100644 index 0000000..cc24a30 --- /dev/null +++ b/crates/db/migrations/20260814010000_ai_credit_packages_role_scoping.up.sql @@ -0,0 +1,17 @@ +-- Role-scoped AI credit packages, mirroring pricing_packages' existing +-- role_key convention (though as an array here since one AI package can +-- reasonably apply to several roles at once, unlike TraceCoin packages +-- which are one row per role). Empty array = visible/purchasable by every +-- role (the default, matching today's actual behavior for the 4 existing +-- packages so nothing changes for them until an admin opts them into +-- specific roles). + +BEGIN; + +ALTER TABLE ai_credit_packages + ADD COLUMN IF NOT EXISTS applicable_roles TEXT[] NOT NULL DEFAULT '{}'; + +CREATE INDEX IF NOT EXISTS idx_ai_credit_packages_applicable_roles + ON ai_credit_packages USING GIN (applicable_roles); + +COMMIT;