diff --git a/docs/rpc/import/README.md b/docs/rpc/import/README.md index 41fcad93..227c59ed 100644 --- a/docs/rpc/import/README.md +++ b/docs/rpc/import/README.md @@ -11,3 +11,5 @@ The RPC API provides a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) int ## Authentication We provide bearer tokens for all trusted sources, you just need to include your token in HTTP request headers. + +Places-source tokens are scoped with an `import_origins` JSON array. Use the source origin, for example `["coinos"]`, to restrict a token to one vendor. Use `["*"]` for a token that can manage submissions for all origins. diff --git a/src/db/main/access_token/blocking_queries.rs b/src/db/main/access_token/blocking_queries.rs index ce952580..07186af4 100644 --- a/src/db/main/access_token/blocking_queries.rs +++ b/src/db/main/access_token/blocking_queries.rs @@ -11,19 +11,36 @@ pub fn insert( secret: &str, roles: &[Role], conn: &Connection, +) -> Result { + insert_with_import_origins(user_id, name, secret, roles, &[], conn) +} + +pub fn insert_with_import_origins( + user_id: i64, + name: &str, + secret: &str, + roles: &[Role], + import_origins: &[String], + conn: &Connection, ) -> Result { let roles: Vec = roles.iter().map(|it| it.to_string()).collect(); let sql = format!( r#" - INSERT INTO {TABLE} ({UserId}, {Name}, {Secret}, {Roles}) - VALUES (?1, ?2, ?3, json(?4)) + INSERT INTO {TABLE} ({UserId}, {Name}, {Secret}, {Roles}, {ImportOrigins}) + VALUES (?1, ?2, ?3, json(?4), json(?5)) RETURNING {projection} "#, projection = AccessToken::projection(), ); conn.query_row( &sql, - params![user_id, name, secret, serde_json::to_string(&roles)?], + params![ + user_id, + name, + secret, + serde_json::to_string(&roles)?, + serde_json::to_string(import_origins)?, + ], AccessToken::mapper(), ) .map_err(Into::into) @@ -92,10 +109,28 @@ mod test { assert_eq!(Some(name), selected_token.name.as_deref()); assert_eq!(secret, selected_token.secret); assert_eq!(roles, selected_token.roles); + assert!(selected_token.import_origins.is_empty()); assert!(selected_token.deleted_at.is_none()); Ok(()) } + #[test] + fn insert_with_import_origins() -> Result<()> { + let conn = conn(); + let import_origins = vec!["square".to_string(), "coinos".to_string()]; + let inserted_token = super::insert_with_import_origins( + 2, + "name", + "secret", + &[Role::PlacesSource], + &import_origins, + &conn, + )?; + let selected_token = super::select_by_id(inserted_token.id, &conn)?; + assert_eq!(import_origins, selected_token.import_origins); + Ok(()) + } + #[test] fn select_all() -> Result<()> { let conn = conn(); diff --git a/src/db/main/access_token/queries.rs b/src/db/main/access_token/queries.rs index eaad8f03..cadde070 100644 --- a/src/db/main/access_token/queries.rs +++ b/src/db/main/access_token/queries.rs @@ -15,6 +15,30 @@ pub async fn insert( .await? } +#[cfg(test)] +pub async fn insert_with_import_origins( + user_id: i64, + name: String, + secret: String, + roles: Vec, + import_origins: Vec, + pool: &Pool, +) -> Result { + pool.get() + .await? + .interact(move |conn| { + blocking_queries::insert_with_import_origins( + user_id, + &name, + &secret, + &roles, + &import_origins, + conn, + ) + }) + .await? +} + pub async fn select_by_secret(secret: String, pool: &Pool) -> Result { pool.get() .await? diff --git a/src/db/main/access_token/schema.rs b/src/db/main/access_token/schema.rs index ea89850c..4f908a87 100644 --- a/src/db/main/access_token/schema.rs +++ b/src/db/main/access_token/schema.rs @@ -14,6 +14,7 @@ pub enum Columns { Name, Secret, Roles, + ImportOrigins, CreatedAt, UpdatedAt, DeletedAt, @@ -27,6 +28,7 @@ pub struct AccessToken { pub name: Option, pub secret: String, pub roles: Vec, + pub import_origins: Vec, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, pub deleted_at: Option, @@ -42,6 +44,7 @@ impl AccessToken { Columns::Name, Columns::Secret, Columns::Roles, + Columns::ImportOrigins, Columns::CreatedAt, Columns::UpdatedAt, Columns::DeletedAt, @@ -61,6 +64,9 @@ impl AccessToken { name: row.get(Columns::Name.as_ref())?, secret: row.get(Columns::Secret.as_ref())?, roles: Self::parse_roles(row.get(Columns::Roles.as_ref())?)?, + import_origins: Self::parse_import_origins( + row.get(Columns::ImportOrigins.as_ref())?, + )?, created_at: row.get(Columns::CreatedAt.as_ref())?, updated_at: row.get(Columns::UpdatedAt.as_ref())?, deleted_at: row.get(Columns::DeletedAt.as_ref())?, @@ -76,4 +82,9 @@ impl AccessToken { .filter_map(|s| Role::from_str(&s).ok()) .collect()) } + + fn parse_import_origins(column_value: Value) -> rusqlite::Result> { + serde_json::from_value(column_value) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + } } diff --git a/src/db/main/migrations/101.sql b/src/db/main/migrations/101.sql new file mode 100644 index 00000000..372661a2 --- /dev/null +++ b/src/db/main/migrations/101.sql @@ -0,0 +1,22 @@ +ALTER TABLE access_token ADD COLUMN import_origins TEXT NOT NULL DEFAULT '[]'; +UPDATE access_token +SET import_origins = json_array('*') +WHERE EXISTS ( + SELECT 1 + FROM json_each(access_token.roles) + WHERE json_each.value = 'places_source' +) + OR user_id IN ( + SELECT id + FROM user + WHERE EXISTS ( + SELECT 1 + FROM json_each(user.roles) + WHERE json_each.value = 'places_source' + ) + ); +DROP TRIGGER acess_token_updated_at; +CREATE TRIGGER acess_token_updated_at UPDATE OF user_id, name, secret, roles, import_origins, created_at, deleted_at ON access_token +BEGIN + UPDATE access_token SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ') WHERE id = old.id; +END; diff --git a/src/db/main/place_submission/mod.rs b/src/db/main/place_submission/mod.rs index daa23303..18d047e5 100644 --- a/src/db/main/place_submission/mod.rs +++ b/src/db/main/place_submission/mod.rs @@ -1,3 +1,4 @@ pub mod blocking_queries; pub mod queries; pub mod schema; +pub mod vendor; diff --git a/src/db/main/place_submission/schema.rs b/src/db/main/place_submission/schema.rs index 3ad59cff..0a69102e 100644 --- a/src/db/main/place_submission/schema.rs +++ b/src/db/main/place_submission/schema.rs @@ -1,3 +1,4 @@ +use super::vendor; use rusqlite::Row; use serde_json::{Map, Value}; use std::sync::OnceLock; @@ -120,261 +121,7 @@ impl PlaceSubmission { } pub fn icon(&self) -> String { - match self.origin.as_str() { - "square" => match self.category.as_str() { - "individual_use" => "person", - "beauty_and_barber_shops" => "content_cut", - "professional_services" => "work", - "clothing_and_accessories" => "checkroom", - "misc_retail" => "storefront", - "music_and_entertainment" => "music_note", - "consultant" => "emoji_people", - "food_stores_convenience_stores_and_specialty_markets" => "storefront", - "personal_services" => "person", - "art_design_and_photography" => "brush", - "contractors" => "engineering", - "charitible_orgs" => "volunteer_activism", - "food_truck_cart" => "lunch_dining", - "medical_services_and_health_practitioners" => "medical_services", - "taxicabs_and_limousines" => "local_taxi", - "retail_shops" => "storefront", - "outdoor_markets" => "storefront", - "restaurants" => "restaurant", - "jewelry_and_watches" => "diamond", - "web_dev_design" => "computer", - "education" => "school", - "apparel_and_accessory_shops" => "apparel", - "membership_organizations" => "groups", - "bakery" => "bakery_dining", - "cultural_attractions" => "museum", - "catering" => "briefcase_meal", - "automotive_services" => "car_repair", - "furniture_home_goods" => "scene", - "health_and_beauty_spas" => "spa", - "cleaning" => "cleaning_services", - "landscaping_and_horticultural_services" => "yard", - "medical_practitioners" => "medical_services", - "coffee_tea_shop" => "local_cafe", - "hobby_shop" => "palette", - "special_trade_contractors" => "engineering", - "membership_clubs" => "groups", - "accounting" => "account_balance", - "delivery_moving_and_storage" => "local_shipping", - "real_estate" => "real_estate_agent", - "bar_club_lounge" => "local_bar", - "recreation_services" => "sports_soccer", - "books_mags_music_and_video" => "library_books", - "legal_services" => "gavel", - "electronics" => "devices", - "repair_shops_and_related_services" => "build", - "computer_equipment_software_maintenance_repair_services" => "computer", - "heating_plumbing_and_air_conditioning" => "ac_unit", - "religious_organization" => "church", - "flowers_and_gifts" => "local_florist", - "grocery_market" => "grocery", - "sporting_goods" => "sports_soccer", - "child_care" => "child_care", - "electrical_services" => "electrical_services", - "theatrical_arts" => "theater_comedy", - "cleaning_services" => "cleaning_services", - "pet_store" => "pets", - "car_washes" => "local_car_wash", - "business_services" => "work", - "printing_services" => "print", - "dentistry" => "dentistry", - "dry_cleaning_and_laundry" => "local_laundry_service", - "movies_film" => "movie", - "esthetic_salon" => "self_care", - "motor_vehicle_supplies" => "directions_car", - "roofing_siding_and_sheet_metal_work_contractors" => "roofing", - "hardware_store" => "hardware", - "utilities" => "electrical_services", - "political_organizations" => "volunteer_activism", - "massage" => "massage", - "sporting_events" => "sports", - "tourism" => "travel_explore", - "carpet_cleaning" => "cleaning_services", - "beauty_parlors_and_barber_shops" => "content_cut", - "cigar_stores_and_stands" => "smoking_rooms", - "sports_facilities" => "sports_soccer", - "other_education" => "school", - "art_dealers_galleries" => "brush", - "veterinary_services" => "pets", - "clothing_shoe_repair_alterations" => "checkroom", - "hotels_and_lodging" => "hotel", - "office_supply" => "business_center", - "direct_marketing_catalog_and_retail_merchant" => "storefront", - "eyewear" => "visibility", - "used_merchandise_and_secondhand_stores" => "storefront", - "pest_control" => "bug_report", - "nail_salon" => "health_and_beauty", - "delivery_services" => "local_shipping", - "optometrist_eye_care" => "visibility", - "garden_supply_shop" => "yard", - "photographer" => "camera_alt", - "book_stores" => "library_books", - "towing_services" => "local_shipping", - "childrens_clothing_stores" => "child_care", - "schools_and_educational_services" => "school", - "antique_store" => "storefront", - "package_stores_beer_wine_and_liquor" => "storefront", - "transportation_services" => "transportation", - "furniture_home_and_office_equipment" => "weekend", - "travel_agencies_and_tour_operators" => "travel_explore", - "direct_marketing_catalog_mail_order_internet_merchant" => "shopping_cart", - "medical_and_dental_labs" => "science", - "hair_removal" => "health_and_beauty", - "womens_accessories" => "health_and_beauty", - "architectural_and_surveying" => "architecture", - "travel_tourism" => "travel_explore", - "watch_jewelry_repair" => "diamond", - "bail_bonds" => "gavel", - "drug_stores_and_pharmacies" => "local_pharmacy", - "prep_cram_schools" => "school", - "antique_reproductions" => "storefront", - "nursing_and_personal_care_facilities" => "health_cross", - "automotive_body_repair_shops" => "car_repair", - "language_schools" => "school", - "amusement_parks" => "attractions", - "dance_schools" => "school", - "funeral_service_and_crematories" => "deceased", - "misc_general_merchandise" => "storefront", - "advertising_services" => "campaign", - "medical_equipment_and_supplies" => "biotech", - "misc_home_furnishing" => "weekend", - "marriage_consultancy" => "favorite", - "automotive_parts_accessories_stores" => "car_repair", - "dairy_product_stores" => "icecream", - "personal_computer_school" => "computer", - "freezer_and_locker_meat_provisioners" => "storefront", - "gift_shop" => "card_giftcard", - "womens_apparel" => "checkroom", - "cosmetic_stores" => "health_and_beauty", - "electronics_repair_shops" => "devices", - "automotive_tire_stores" => "car_repair", - "sporting_and_recreational_camps" => "sports_soccer", - "tailors_and_alterations" => "checkroom", - "candy_nut_and_confectionery_stores" => "cookie", - "car_and_truck_dealers" => "directions_car", - "artists_supply_and_craft_shops" => "palette", - "parking_lots_and_garages" => "local_parking", - "hardware_equipment_and_supplies" => "build", - "used_automobile_dealers" => "directions_car", - "swimming_pools_sales_service" => "pool", - "bicycle_shops" => "directions_bike", - "tutoring" => "school", - "rv_parks_and_campgrounds" => "camping", - "household_appliance_store" => "kitchen", - "marinas_service_and_supplies" => "directions_boat", - "protective_security_services" => "security", - "service_stations" => "local_gas_station", - "public_warehousing_and_storage" => "warehouse", - "florist_supplies" => "local_florist", - "family_apparel" => "apparel", - "sewing_stores" => "checkroom", - "furniture_repair_and_refinishing" => "weekend", - "concrete_work_contractors" => "engineering", - "ticket_sales" => "confirmation_number", - "agricultural_cooperatives" => "agriculture", - "floor_covering_stores" => "hardware", - "misc_publishing_and_printing" => "print", - "computers_peripheral_equipment_and_software" => "computer", - "home_supply_warehouse_stores" => "construction", - "misc_automotive_dealers" => "directions_car", - "record_shops" => "music_note", - "automotive_paint_shops" => "directions_car", - "wholesale_books_periodicals_and_newspapers" => "library_books", - "sports_stores" => "sports_soccer", - "plumbing_heating_equipment" => "plumbing", - "music_instruments_and_sheet_music" => "music_note", - "misc_nondurable_goods" => "storefront", - "durable_goods" => "storefront", - "religious_goods_stores" => "church", - "misc_commercial_equipment" => "storefront", - "tire_retreading_and_repair_shops" => "car_repair", - "wig_and_toupee_stores" => "face", - "carpentry_contractors" => "carpenter", - "bus_lines" => "directions_bus", - "construction_materials" => "construction", - "drapery_window_covering_and_upholstery" => "window", - "truck_and_utility_trailer_rentals" => "local_shipping", - "tool_furniture_rental" => "build", - "shoe_stores" => "storefront", - "lumber_and_building_materials_stores" => "construction", - "mens_apparel_and_accessory_shops" => "apparel", - "industrial_supplies" => "storefront", - "welding_repair" => "engineering", - "telecom_equipment" => "settings_input_antenna", - "metal_service_centers" => "engineering", - "electrical_parts_and_equipment" => "electrical_services", - "insulation_stonework_contractors" => "engineering", - "motor_home_recreational_vehicle_rentals" => "hotel", - "cable_and_pay_television" => "tv", - "motion_pictures_and_video_production_distribution" => "movie", - "luggage_and_leather_goods_stores" => "luggage", - "fuel_oil_liquefied_petroleum" => "local_gas_station", - "uniforms_and_commercial_clothing" => "checkroom", - "photo_developing" => "camera", - "glassware_crystal_stores" => "storefront", - "fabric_wholesale" => "storefront", - "small_appliance_repair" => "kitchen", - "motorcycle_dealers" => "two_wheeler", - "airports_terminals_flying_fields" => "flight", - "air_conditioning_repair" => "settings", - "grocery" => "grocery", - "insurance" => "verified_user", - "hearing_aids_sales_service_stores" => "hearing", - "pawn_shops" => "attach_money", - "stenographic_and_secreterial_services" => "work", - "nonmedical_testing_labs" => "science", - "discount_stores" => "sell", - "ambulance_services" => "medical_services", - "video_game_arcades_establishments" => "sports_esports", - "office_supplies" => "business_center", - "glass_paint_and_wallpaper_stores" => "imagesearch_roller", - "aquariums" => "waves", - "golf_courses" => "golf_course", - "typesetting_platemaking_services" => "print", - "variety_stores" => "storefront", - "news_dealers_and_newstands" => "library_books", - "clothing_retail" => "checkroom", - "tanning_salon" => "bath_bedrock", - "petroleum_and_petroleum_products" => "local_gas_station", - "wholesale_clubs" => "groups", - "fireplace_stores" => "fireplace", - "chemical_and_allied_products" => "science", - "camera_and_photographic_supply_stores" => "photo_camera", - "paints_varnishes_and_supplies" => "format_paint", - "hospitals" => "local_hospital", - "video_amusement_game_supplies" => "sports_esports", - "orthepedic_goods_prosthetic_devices" => "accessibility", - "office_and_commercial_furniture" => "desk", - "office_photographic_copy_film_equipment" => "business_center", - "bowling_alleys" => "sports", - "wrecking_and_salvage_yards" => "construction", - "mobile_home_dealers" => "rv_hookup", - "commercial_footwear" => "checkroom", - "commuter_transportation" => "commute", - "department_stores" => "storefront", - "financial_institution" => "account_balance", - "duty_free_store" => "storefront", - "movie_rental_stores" => "movie", - "furriers_and_fur_shops" => "checkroom", - "tent_and_awning_shops" => "camping", - "charitable_social_service_organizations" => "volunteer_activism", - "electric_razor_stores" => "store", - "billiard_and_pool_establishments" => "sports", - "typewriter_sales_rental_stores" => "keyboard", - "passenger_railways" => "train", - "ghost_kitchen" => "restaurant", - "apparel" => "apparel", - "garden_supply" => "yard", - "cigar_stands" => "smoking_rooms", - _ => "currency_bitcoin", - }, - _ => "store", - } - .to_string() + "store".to_string() } pub fn description(&self) -> Option { @@ -494,11 +241,9 @@ impl PlaceSubmission { } pub fn payment_provider(&self) -> Option { - if self.origin == "square" { - Some(self.origin.clone()) - } else { - None - } + vendor::get(&self.origin) + .and_then(|vendor| vendor.payment_provider) + .map(Into::into) } } diff --git a/src/db/main/place_submission/vendor.rs b/src/db/main/place_submission/vendor.rs new file mode 100644 index 00000000..1af9e467 --- /dev/null +++ b/src/db/main/place_submission/vendor.rs @@ -0,0 +1,50 @@ +pub struct Vendor { + pub origin: &'static str, + pub sync_enabled: bool, + pub payment_provider: Option<&'static str>, + pub payment_tag_name: Option<&'static str>, + pub payment_tag_value: Option<&'static str>, + pub gitea_label_ids: &'static [i64], +} + +const VENDORS: &[Vendor] = &[ + Vendor { + origin: "square", + sync_enabled: true, + payment_provider: Some("square"), + payment_tag_name: Some("payment:lightning:operator"), + payment_tag_value: Some("square"), + gitea_label_ids: &[1307], + }, + Vendor { + origin: "coinos", + sync_enabled: true, + payment_provider: Some("coinos"), + payment_tag_name: Some("payment:coinos"), + payment_tag_value: Some("yes"), + gitea_label_ids: &[], + }, + Vendor { + origin: "btcpayserver", + sync_enabled: true, + payment_provider: Some("btcpayserver"), + payment_tag_name: Some("payment:btcpayserver"), + payment_tag_value: Some("yes"), + gitea_label_ids: &[1538], + }, +]; + +pub fn get(origin: &str) -> Option<&'static Vendor> { + VENDORS.iter().find(|vendor| vendor.origin == origin) +} + +pub fn origin_for_payment_tag(tag_name: &str, tag_value: &str) -> Option<&'static str> { + VENDORS.iter().find_map(|vendor| { + if vendor.payment_tag_name == Some(tag_name) && vendor.payment_tag_value == Some(tag_value) + { + Some(vendor.origin) + } else { + None + } + }) +} diff --git a/src/db/main/schema.sql b/src/db/main/schema.sql index 73afb241..0a80b264 100644 --- a/src/db/main/schema.sql +++ b/src/db/main/schema.sql @@ -108,6 +108,7 @@ CREATE TABLE access_token( name TEXT, secret TEXT NOT NULL, roles TEXT NOT NULL DEFAULT '[]', + import_origins TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ')), deleted_at TEXT @@ -159,7 +160,7 @@ CREATE TRIGGER element_issue_updated_at UPDATE OF element_id, code, severity, cr BEGIN UPDATE element_issue SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ') WHERE id = old.id; END; -CREATE TRIGGER acess_token_updated_at UPDATE OF user_id, name, secret, roles, created_at, deleted_at ON access_token +CREATE TRIGGER acess_token_updated_at UPDATE OF user_id, name, secret, roles, import_origins, created_at, deleted_at ON access_token BEGIN UPDATE access_token SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ') WHERE id = old.id; END; diff --git a/src/rest/v4/places.rs b/src/rest/v4/places.rs index 639908b9..b7e292c6 100644 --- a/src/rest/v4/places.rs +++ b/src/rest/v4/places.rs @@ -384,13 +384,9 @@ pub async fn search(args: Query, pool: Data) -> Res, pool: Data) -> Res false, }); if include_pending { - let origin = if tag_name == "payment:lightning:operator" && tag_value == "square" { - "square" - } else { - "" - }; - - if !origin.is_empty() { + if let Some(origin) = db::main::place_submission::vendor::origin_for_payment_tag( + &tag_name, &tag_value, + ) { pending_matches.retain(|it| it.origin == origin); + } else { + pending_matches.clear(); } } } diff --git a/src/rpc/handler.rs b/src/rpc/handler.rs index 46080951..ec9d2c5f 100644 --- a/src/rpc/handler.rs +++ b/src/rpc/handler.rs @@ -321,7 +321,7 @@ pub async fn handle( }))); } - let user = match bearer_token { + let auth_token = match bearer_token { Some(bearer_token) => { let bearer_token = db::main::access_token::queries::select_by_secret(bearer_token, &pool).await?; @@ -341,12 +341,24 @@ pub async fn handle( data: None, }))); } - Some(user) + Some((bearer_token, user)) } None => None, }; + let effective_roles = auth_token + .as_ref() + .map(|(token, user)| { + if token.roles.is_empty() { + user.roles.as_slice() + } else { + token.roles.as_slice() + } + }) + .unwrap_or(&[]); + let user = auth_token.as_ref().map(|(_, user)| user); + if req.jsonrpc != "2.0" { return Ok(Json(RpcResponse::invalid_request(Value::Null))); } @@ -354,7 +366,7 @@ pub async fn handle( let res: RpcResponse = match req.method { RpcMethod::Whoami => RpcResponse::from( req.id.clone(), - super::auth::whoami::run(&user.unwrap()).await?, + super::auth::whoami::run(user.unwrap()).await?, ), // element RpcMethod::GetElement => RpcResponse::from( @@ -545,18 +557,33 @@ pub async fn handle( req.id.clone(), super::event::delete_event::run(params(req.params)?, &pool).await?, ), - RpcMethod::SubmitPlace => RpcResponse::from( - req.id.clone(), - super::import::submit_place::run(params(req.params)?, &pool).await?, - ), - RpcMethod::RevokeSubmittedPlace => RpcResponse::from( - req.id.clone(), - super::import::revoke_submitted_place::run(params(req.params)?, &pool).await?, - ), - RpcMethod::GetSubmittedPlace => RpcResponse::from( - req.id.clone(), - super::import::get_submitted_place::run(params(req.params)?, &pool).await?, - ), + RpcMethod::SubmitPlace => { + let params: super::import::submit_place::Params = params(req.params)?; + let token = &auth_token.as_ref().unwrap().0; + super::import::ensure_can_access_origin(effective_roles, token, ¶ms.origin)?; + RpcResponse::from( + req.id.clone(), + super::import::submit_place::run(params, &pool).await?, + ) + } + RpcMethod::GetSubmittedPlace => { + let params: super::import::get_submitted_place::Params = params(req.params)?; + let token = &auth_token.as_ref().unwrap().0; + RpcResponse::from( + req.id.clone(), + super::import::get_submitted_place::run(params, effective_roles, token, &pool) + .await?, + ) + } + RpcMethod::RevokeSubmittedPlace => { + let params: super::import::revoke_submitted_place::Params = params(req.params)?; + let token = &auth_token.as_ref().unwrap().0; + RpcResponse::from( + req.id.clone(), + super::import::revoke_submitted_place::run(params, effective_roles, token, &pool) + .await?, + ) + } RpcMethod::SyncSubmittedPlaces => RpcResponse::from( req.id.clone(), super::import::sync_submitted_places::run(&pool).await?, @@ -804,6 +831,140 @@ mod test { Ok(()) } + #[test] + async fn get_submitted_place_id_origin_bypass_blocked() -> Result<()> { + let pool = pool(); + + // Insert a square submission + let square_submission = db::main::place_submission::blocking_queries::InsertArgs { + origin: "square".to_string(), + external_id: "123".to_string(), + lat: 1.0, + lon: 2.0, + category: "test".to_string(), + name: "Square Place".to_string(), + extra_fields: serde_json::Map::new(), + }; + db::main::place_submission::queries::insert(square_submission, &pool).await?; + + // Create a user with PlacesSource role and a token scoped to coinos + let user = db::main::user::queries::insert("source_user", "", &pool).await?; + let _token = db::main::access_token::queries::insert_with_import_origins( + user.id, + "".to_string(), + "scoped_secret".to_string(), + vec![Role::PlacesSource], + vec!["coinos".to_string()], + &pool, + ) + .await?; + + let conf = Conf::mock(); + let client: Option = None; + let log_pool = log_pool(); + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .app_data(Data::new(conf)) + .app_data(Data::new(client)) + .app_data(Data::new(log_pool)) + .service(scope("/").service(super::handle)), + ) + .await; + + // Try to access square submission by id, but supply coinos origin to bypass pre-check + let req = test::TestRequest::post() + .uri("/") + .insert_header((header::AUTHORIZATION, "Bearer scoped_secret")) + .set_json(&json!({ + "jsonrpc": "2.0", + "method": "get_submitted_place", + "params": {"id": 1, "origin": "coinos"}, + "id": 1 + })) + .to_request(); + + let res = test::call_service(&app, req).await; + let body = test::read_body(res).await; + let body_str = String::from_utf8_lossy(&body); + assert!( + body_str.contains("coinos") || body_str.contains("not allowed"), + "should have rejected access to square submission with coinos-scoped token; body: {body_str}" + ); + Ok(()) + } + + #[test] + async fn revoke_submitted_place_id_origin_bypass_blocked() -> Result<()> { + let pool = pool(); + + // Insert a square submission + let square_submission = db::main::place_submission::blocking_queries::InsertArgs { + origin: "square".to_string(), + external_id: "123".to_string(), + lat: 1.0, + lon: 2.0, + category: "test".to_string(), + name: "Square Place".to_string(), + extra_fields: serde_json::Map::new(), + }; + db::main::place_submission::queries::insert(square_submission, &pool).await?; + + // Create a user with PlacesSource role and a token scoped to coinos + let user = db::main::user::queries::insert("source_user", "", &pool).await?; + let _token = db::main::access_token::queries::insert_with_import_origins( + user.id, + "".to_string(), + "scoped_secret".to_string(), + vec![Role::PlacesSource], + vec!["coinos".to_string()], + &pool, + ) + .await?; + + let conf = Conf::mock(); + let client: Option = None; + let log_pool = log_pool(); + let app = test::init_service( + App::new() + .app_data(Data::new(pool.clone())) + .app_data(Data::new(conf)) + .app_data(Data::new(client)) + .app_data(Data::new(log_pool)) + .service(scope("/").service(super::handle)), + ) + .await; + + // Try to revoke square submission by id, but supply coinos origin to bypass pre-check + let req = test::TestRequest::post() + .uri("/") + .insert_header((header::AUTHORIZATION, "Bearer scoped_secret")) + .set_json(&json!({ + "jsonrpc": "2.0", + "method": "revoke_submitted_place", + "params": {"id": 1, "origin": "coinos"}, + "id": 1 + })) + .to_request(); + + let res = test::call_service(&app, req).await; + let body = test::read_body(res).await; + let body_str = String::from_utf8_lossy(&body); + assert!( + body_str.contains("coinos") || body_str.contains("not allowed"), + "should have rejected revocation of square submission with coinos-scoped token; body: {body_str}" + ); + + // Verify the submission was NOT revoked + let submission = db::main::place_submission::queries::select_by_id(1, &pool).await?; + assert!( + !submission.revoked, + "submission should not have been revoked" + ); + + Ok(()) + } + #[test] async fn unauthorized_method() { let pool = pool(); diff --git a/src/rpc/import/get_submitted_place.rs b/src/rpc/import/get_submitted_place.rs index 6158ba5c..7859775d 100644 --- a/src/rpc/import/get_submitted_place.rs +++ b/src/rpc/import/get_submitted_place.rs @@ -1,4 +1,5 @@ use crate::{ + db::main::{access_token::schema::AccessToken, user::schema::Role}, db::{self}, Result, }; @@ -8,36 +9,46 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct Params { - id: Option, - origin: Option, - external_id: Option, + pub id: Option, + pub origin: Option, + pub external_id: Option, } #[derive(Serialize)] pub struct Res { - id: i64, - origin: String, - external_id: String, - lat: f64, - lon: f64, - category: String, - name: String, - extra_fields: JsonObject, - revoked: bool, + pub id: i64, + pub origin: String, + pub external_id: String, + pub lat: f64, + pub lon: f64, + pub category: String, + pub name: String, + pub extra_fields: JsonObject, + pub revoked: bool, } -pub async fn run(params: Params, pool: &Pool) -> Result { +pub async fn run(params: Params, roles: &[Role], token: &AccessToken, pool: &Pool) -> Result { let submission = match params.id { Some(id) => db::main::place_submission::queries::select_by_id(id, pool).await?, - None => db::main::place_submission::queries::select_by_origin_and_external_id( - params.origin.unwrap(), - params.external_id.unwrap(), - pool, - ) - .await? - .unwrap(), + None => { + let Some(origin) = params.origin else { + return Err("missing parameter: origin or id".into()); + }; + let Some(external_id) = params.external_id else { + return Err("missing parameter: external_id or id".into()); + }; + db::main::place_submission::queries::select_by_origin_and_external_id( + origin, + external_id, + pool, + ) + .await? + .ok_or("can't find place with provided origin and external_id")? + } }; + super::ensure_can_access_origin(roles, token, &submission.origin)?; + Ok(Res { id: submission.id, origin: submission.origin, diff --git a/src/rpc/import/mod.rs b/src/rpc/import/mod.rs index af2cb5ab..e3d007b2 100644 --- a/src/rpc/import/mod.rs +++ b/src/rpc/import/mod.rs @@ -2,3 +2,81 @@ pub mod get_submitted_place; pub mod revoke_submitted_place; pub mod submit_place; pub mod sync_submitted_places; + +use crate::db::main::{access_token::schema::AccessToken, user::schema::Role}; + +pub const IMPORT_ORIGIN_WILDCARD: &str = "*"; + +pub fn can_access_origin(roles: &[Role], token: &AccessToken, origin: &str) -> bool { + if roles + .iter() + .any(|role| matches!(role, Role::Admin | Role::Root)) + { + return true; + } + + roles.iter().any(|role| matches!(role, Role::PlacesSource)) + && token.import_origins.iter().any(|allowed_origin| { + allowed_origin == IMPORT_ORIGIN_WILDCARD || allowed_origin == origin + }) +} + +pub fn ensure_can_access_origin( + roles: &[Role], + token: &AccessToken, + origin: &str, +) -> crate::Result<()> { + if can_access_origin(roles, token, origin) { + Ok(()) + } else { + Err(format!("token is not allowed to access import origin '{origin}'").into()) + } +} + +#[cfg(test)] +mod test { + use crate::db::main::access_token::schema::AccessToken; + use crate::db::main::user::schema::Role; + use time::OffsetDateTime; + + fn token(roles: Vec, import_origins: Vec) -> AccessToken { + AccessToken { + id: 1, + user_id: 1, + name: Some("source".to_string()), + secret: "secret".to_string(), + roles, + import_origins, + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + deleted_at: None, + } + } + + #[test] + fn places_source_can_access_scoped_origin() { + let token = token(vec![Role::PlacesSource], vec!["square".to_string()]); + + assert!(super::can_access_origin(&token.roles, &token, "square")); + assert!(!super::can_access_origin(&token.roles, &token, "coinos")); + } + + #[test] + fn places_source_wildcard_can_access_all_origins() { + let token = token( + vec![Role::PlacesSource], + vec![super::IMPORT_ORIGIN_WILDCARD.to_string()], + ); + + assert!(super::can_access_origin(&token.roles, &token, "square")); + assert!(super::can_access_origin(&token.roles, &token, "coinos")); + } + + #[test] + fn admin_can_access_all_origins_without_scope() { + let token = token(vec![Role::Admin], vec![]); + + assert!(super::can_access_origin(&token.roles, &token, "square")); + assert!(super::can_access_origin(&token.roles, &token, "coinos")); + } +} diff --git a/src/rpc/import/revoke_submitted_place.rs b/src/rpc/import/revoke_submitted_place.rs index caadde0b..5b695bfd 100644 --- a/src/rpc/import/revoke_submitted_place.rs +++ b/src/rpc/import/revoke_submitted_place.rs @@ -1,4 +1,5 @@ use crate::{ + db::main::{access_token::schema::AccessToken, user::schema::Role}, db::{self}, Result, }; @@ -7,9 +8,9 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct Params { - id: Option, - origin: Option, - external_id: Option, + pub id: Option, + pub origin: Option, + pub external_id: Option, } #[derive(Serialize)] @@ -20,13 +21,19 @@ pub struct Res { pub revoked: bool, } -pub async fn run(params: Params, pool: &Pool) -> Result { +pub async fn run(params: Params, roles: &[Role], token: &AccessToken, pool: &Pool) -> Result { let submission = match params.id { Some(id) => Some(db::main::place_submission::queries::select_by_id(id, pool).await?), None => { + let Some(origin) = params.origin else { + return Err("missing parameter: origin or id".into()); + }; + let Some(external_id) = params.external_id else { + return Err("missing parameter: external_id or id".into()); + }; db::main::place_submission::queries::select_by_origin_and_external_id( - params.origin.unwrap(), - params.external_id.unwrap(), + origin, + external_id, pool, ) .await? @@ -37,6 +44,8 @@ pub async fn run(params: Params, pool: &Pool) -> Result { return Err("can't find place with provided id".into()); }; + super::ensure_can_access_origin(roles, token, &submission.origin)?; + let submission = db::main::place_submission::queries::set_revoked(submission.id, true, pool).await?; @@ -51,7 +60,9 @@ pub async fn run(params: Params, pool: &Pool) -> Result { #[cfg(test)] mod test { use crate::{ + db::main::access_token::schema::AccessToken, db::main::place_submission::blocking_queries::InsertArgs, + db::main::user::schema::Role, db::{self, main::test::pool}, Result, }; @@ -83,12 +94,26 @@ mod test { assert_eq!(false, submission.revoked); + let admin_token = AccessToken { + id: 1, + user_id: 1, + name: None, + secret: "secret".to_string(), + roles: vec![Role::Admin], + import_origins: vec![], + created_at: time::OffsetDateTime::UNIX_EPOCH, + updated_at: time::OffsetDateTime::UNIX_EPOCH, + deleted_at: None, + }; + let res = super::run( super::Params { id: None, origin: Some(origin.into()), external_id: Some(external_id.into()), }, + &[Role::Admin], + &admin_token, &pool, ) .await?; diff --git a/src/rpc/import/submit_place.rs b/src/rpc/import/submit_place.rs index 617c5407..f6d06d12 100644 --- a/src/rpc/import/submit_place.rs +++ b/src/rpc/import/submit_place.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize, Clone)] pub struct Params { - origin: String, + pub origin: String, external_id: String, lat: f64, lon: f64, @@ -160,4 +160,37 @@ mod test { Ok(()) } + + #[test] + async fn submit_place_allows_same_external_id_for_different_origins() -> Result<()> { + let pool = pool(); + + let square_params = super::Params { + origin: "square".into(), + external_id: "merchant-1".into(), + lat: 1.23, + lon: 3.45, + category: "restaurants".into(), + name: "Square Place".into(), + extra_fields: None, + }; + let coinos_params = super::Params { + origin: "coinos".into(), + external_id: "merchant-1".into(), + lat: 1.23, + lon: 3.45, + category: "cafe".into(), + name: "Coinos Place".into(), + extra_fields: None, + }; + + let square_res = super::run(square_params, &pool).await?; + let coinos_res = super::run(coinos_params, &pool).await?; + + assert_ne!(square_res.id, coinos_res.id); + assert_eq!("merchant-1", square_res.external_id); + assert_eq!("merchant-1", coinos_res.external_id); + + Ok(()) + } } diff --git a/src/rpc/import/sync_submitted_places.rs b/src/rpc/import/sync_submitted_places.rs index 71ec9f7f..71fb2784 100644 --- a/src/rpc/import/sync_submitted_places.rs +++ b/src/rpc/import/sync_submitted_places.rs @@ -16,6 +16,8 @@ pub struct Res { issues_closed: i64, } +const LOCATION_SUBMISSION_LABEL_ID: i64 = 901; + pub async fn run(pool: &Pool) -> Result { let submissions = db::main::place_submission::queries::select_open_and_not_revoked(pool).await?; @@ -24,12 +26,16 @@ pub async fn run(pool: &Pool) -> Result { "fetched open and non-revoked submissions", ); - let enabled_origins = ["square".to_string()]; let mut issues_created = 0; let mut issues_closed = 0; for submission in &submissions { - if !enabled_origins.contains(&submission.origin) { + let Some(vendor) = db::main::place_submission::vendor::get(&submission.origin) else { + warn!(submission.origin, "unknown origin"); + continue; + }; + + if !vendor.sync_enabled { warn!(submission.origin, "disabled origin"); continue; } @@ -75,7 +81,9 @@ pub async fn run(pool: &Pool) -> Result { .map(|line| line.trim()) .collect::>() .join("\n"); - let issue = service::gitea::create_issue(title, body, vec![901, 1307], pool).await?; + let mut label_ids = vec![LOCATION_SUBMISSION_LABEL_ID]; + label_ids.extend_from_slice(vendor.gitea_label_ids); + let issue = service::gitea::create_issue(title, body, label_ids, pool).await?; db::main::place_submission::queries::set_ticket_url( submission.id, issue.url.clone(),