max / makenotwork
13 files changed,
+439 insertions,
-147 deletions
| @@ -11,6 +11,11 @@ pub struct UserInfo { | |||
| 11 | 11 | pub creator_tier: Option<String>, | |
| 12 | 12 | pub can_create_projects: bool, | |
| 13 | 13 | pub suspended: bool, | |
| 14 | + | /// Signed actor assertion the server mints at lookup; forwarded as | |
| 15 | + | /// `X-MNW-Actor` on internal calls so the server derives identity from an | |
| 16 | + | /// SSH-authenticated token rather than a caller-supplied `user_id`. | |
| 17 | + | #[serde(default)] | |
| 18 | + | pub actor_token: String, | |
| 14 | 19 | } | |
| 15 | 20 | ||
| 16 | 21 | /// A creator's project with item count and revenue. | |
| @@ -305,6 +310,9 @@ pub struct MnwApiClient { | |||
| 305 | 310 | http: reqwest::Client, | |
| 306 | 311 | base_url: String, | |
| 307 | 312 | service_token: String, | |
| 313 | + | /// Set once per session from the SSH-key-lookup response; forwarded on | |
| 314 | + | /// internal creator calls as `X-MNW-Actor`. | |
| 315 | + | actor_token: Option<String>, | |
| 308 | 316 | } | |
| 309 | 317 | ||
| 310 | 318 | impl MnwApiClient { | |
| @@ -318,9 +326,21 @@ impl MnwApiClient { | |||
| 318 | 326 | http, | |
| 319 | 327 | base_url, | |
| 320 | 328 | service_token, | |
| 329 | + | actor_token: None, | |
| 321 | 330 | } | |
| 322 | 331 | } | |
| 323 | 332 | ||
| 333 | + | /// Record the actor assertion for the authenticated session. Subsequent | |
| 334 | + | /// internal calls forward it so the server can verify the acting identity. | |
| 335 | + | pub fn set_actor_token(&mut self, token: String) { | |
| 336 | + | self.actor_token = Some(token); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | /// The `X-MNW-Actor` header value for internal calls (empty before lookup). | |
| 340 | + | fn actor_header(&self) -> &str { | |
| 341 | + | self.actor_token.as_deref().unwrap_or("") | |
| 342 | + | } | |
| 343 | + | ||
| 324 | 344 | /// Look up a user by SSH key fingerprint. | |
| 325 | 345 | /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. | |
| 326 | 346 | pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> { | |
| @@ -329,6 +349,7 @@ impl MnwApiClient { | |||
| 329 | 349 | .http | |
| 330 | 350 | .get(&url) | |
| 331 | 351 | .bearer_auth(&self.service_token) | |
| 352 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 332 | 353 | .query(&[("fingerprint", fingerprint)]) | |
| 333 | 354 | .send() | |
| 334 | 355 | .await?; | |
| @@ -352,6 +373,7 @@ impl MnwApiClient { | |||
| 352 | 373 | .http | |
| 353 | 374 | .get(&url) | |
| 354 | 375 | .bearer_auth(&self.service_token) | |
| 376 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 355 | 377 | .query(&[("user_id", user_id)]) | |
| 356 | 378 | .send() | |
| 357 | 379 | .await?; | |
| @@ -380,6 +402,7 @@ impl MnwApiClient { | |||
| 380 | 402 | .http | |
| 381 | 403 | .post(&url) | |
| 382 | 404 | .bearer_auth(&self.service_token) | |
| 405 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 383 | 406 | .json(&body) | |
| 384 | 407 | .send() | |
| 385 | 408 | .await?; | |
| @@ -401,6 +424,7 @@ impl MnwApiClient { | |||
| 401 | 424 | .http | |
| 402 | 425 | .get(&url) | |
| 403 | 426 | .bearer_auth(&self.service_token) | |
| 427 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 404 | 428 | .query(&[("user_id", user_id)]) | |
| 405 | 429 | .send() | |
| 406 | 430 | .await?; | |
| @@ -415,6 +439,7 @@ impl MnwApiClient { | |||
| 415 | 439 | .http | |
| 416 | 440 | .get(&url) | |
| 417 | 441 | .bearer_auth(&self.service_token) | |
| 442 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 418 | 443 | .query(&[("user_id", user_id), ("range", range)]) | |
| 419 | 444 | .send() | |
| 420 | 445 | .await?; | |
| @@ -429,6 +454,7 @@ impl MnwApiClient { | |||
| 429 | 454 | .http | |
| 430 | 455 | .get(&url) | |
| 431 | 456 | .bearer_auth(&self.service_token) | |
| 457 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 432 | 458 | .query(&[("user_id", user_id)]) | |
| 433 | 459 | .send() | |
| 434 | 460 | .await?; | |
| @@ -450,6 +476,7 @@ impl MnwApiClient { | |||
| 450 | 476 | .http | |
| 451 | 477 | .post(&url) | |
| 452 | 478 | .bearer_auth(&self.service_token) | |
| 479 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 453 | 480 | .json(&serde_json::json!({ | |
| 454 | 481 | "user_id": user_id, | |
| 455 | 482 | "project_id": project_id, | |
| @@ -477,6 +504,7 @@ impl MnwApiClient { | |||
| 477 | 504 | .http | |
| 478 | 505 | .post(&url) | |
| 479 | 506 | .bearer_auth(&self.service_token) | |
| 507 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 480 | 508 | .json(&serde_json::json!({ | |
| 481 | 509 | "user_id": user_id, | |
| 482 | 510 | "item_id": item_id, | |
| @@ -503,6 +531,7 @@ impl MnwApiClient { | |||
| 503 | 531 | .http | |
| 504 | 532 | .post(&url) | |
| 505 | 533 | .bearer_auth(&self.service_token) | |
| 534 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 506 | 535 | .json(&serde_json::json!({ | |
| 507 | 536 | "user_id": user_id, | |
| 508 | 537 | "item_id": item_id, | |
| @@ -531,6 +560,7 @@ impl MnwApiClient { | |||
| 531 | 560 | .http | |
| 532 | 561 | .get(&url) | |
| 533 | 562 | .bearer_auth(&self.service_token) | |
| 563 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 534 | 564 | .query(&[("user_id", user_id)]) | |
| 535 | 565 | .send() | |
| 536 | 566 | .await?; | |
| @@ -567,6 +597,7 @@ impl MnwApiClient { | |||
| 567 | 597 | .http | |
| 568 | 598 | .put(&url) | |
| 569 | 599 | .bearer_auth(&self.service_token) | |
| 600 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 570 | 601 | .json(&body) | |
| 571 | 602 | .send() | |
| 572 | 603 | .await?; | |
| @@ -581,6 +612,7 @@ impl MnwApiClient { | |||
| 581 | 612 | .http | |
| 582 | 613 | .delete(&url) | |
| 583 | 614 | .bearer_auth(&self.service_token) | |
| 615 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 584 | 616 | .query(&[("user_id", user_id)]) | |
| 585 | 617 | .send() | |
| 586 | 618 | .await?; | |
| @@ -602,6 +634,7 @@ impl MnwApiClient { | |||
| 602 | 634 | .http | |
| 603 | 635 | .post(&url) | |
| 604 | 636 | .bearer_auth(&self.service_token) | |
| 637 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 605 | 638 | .json(&serde_json::json!({ "user_id": user_id })) | |
| 606 | 639 | .send() | |
| 607 | 640 | .await?; | |
| @@ -623,6 +656,7 @@ impl MnwApiClient { | |||
| 623 | 656 | .http | |
| 624 | 657 | .post(&url) | |
| 625 | 658 | .bearer_auth(&self.service_token) | |
| 659 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 626 | 660 | .json(&serde_json::json!({ "user_id": user_id })) | |
| 627 | 661 | .send() | |
| 628 | 662 | .await?; | |
| @@ -644,6 +678,7 @@ impl MnwApiClient { | |||
| 644 | 678 | .http | |
| 645 | 679 | .get(&url) | |
| 646 | 680 | .bearer_auth(&self.service_token) | |
| 681 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 647 | 682 | .query(&[("user_id", user_id)]) | |
| 648 | 683 | .send() | |
| 649 | 684 | .await?; | |
| @@ -695,6 +730,7 @@ impl MnwApiClient { | |||
| 695 | 730 | .http | |
| 696 | 731 | .get(&url) | |
| 697 | 732 | .bearer_auth(&self.service_token) | |
| 733 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 698 | 734 | .query(&[("user_id", user_id)]) | |
| 699 | 735 | .send() | |
| 700 | 736 | .await?; | |
| @@ -727,6 +763,7 @@ impl MnwApiClient { | |||
| 727 | 763 | .http | |
| 728 | 764 | .post(&url) | |
| 729 | 765 | .bearer_auth(&self.service_token) | |
| 766 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 730 | 767 | .json(&body) | |
| 731 | 768 | .send() | |
| 732 | 769 | .await?; | |
| @@ -741,6 +778,7 @@ impl MnwApiClient { | |||
| 741 | 778 | .http | |
| 742 | 779 | .delete(&url) | |
| 743 | 780 | .bearer_auth(&self.service_token) | |
| 781 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 744 | 782 | .query(&[("user_id", user_id)]) | |
| 745 | 783 | .send() | |
| 746 | 784 | .await?; | |
| @@ -757,6 +795,7 @@ impl MnwApiClient { | |||
| 757 | 795 | .http | |
| 758 | 796 | .get(&url) | |
| 759 | 797 | .bearer_auth(&self.service_token) | |
| 798 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 760 | 799 | .query(&[("user_id", user_id)]) | |
| 761 | 800 | .send() | |
| 762 | 801 | .await?; | |
| @@ -793,6 +832,7 @@ impl MnwApiClient { | |||
| 793 | 832 | .http | |
| 794 | 833 | .post(&url) | |
| 795 | 834 | .bearer_auth(&self.service_token) | |
| 835 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 796 | 836 | .json(&body) | |
| 797 | 837 | .send() | |
| 798 | 838 | .await?; | |
| @@ -810,6 +850,7 @@ impl MnwApiClient { | |||
| 810 | 850 | .http | |
| 811 | 851 | .delete(&url) | |
| 812 | 852 | .bearer_auth(&self.service_token) | |
| 853 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 813 | 854 | .query(&[("user_id", user_id)]) | |
| 814 | 855 | .send() | |
| 815 | 856 | .await?; | |
| @@ -833,6 +874,7 @@ impl MnwApiClient { | |||
| 833 | 874 | .http | |
| 834 | 875 | .get(&url) | |
| 835 | 876 | .bearer_auth(&self.service_token) | |
| 877 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 836 | 878 | .query(&[("user_id", user_id)]) | |
| 837 | 879 | .send() | |
| 838 | 880 | .await?; | |
| @@ -854,6 +896,7 @@ impl MnwApiClient { | |||
| 854 | 896 | .http | |
| 855 | 897 | .post(&url) | |
| 856 | 898 | .bearer_auth(&self.service_token) | |
| 899 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 857 | 900 | .json(&serde_json::json!({ "user_id": user_id })) | |
| 858 | 901 | .send() | |
| 859 | 902 | .await?; | |
| @@ -875,6 +918,7 @@ impl MnwApiClient { | |||
| 875 | 918 | .http | |
| 876 | 919 | .post(&url) | |
| 877 | 920 | .bearer_auth(&self.service_token) | |
| 921 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 878 | 922 | .json(&serde_json::json!({ "user_id": user_id })) | |
| 879 | 923 | .send() | |
| 880 | 924 | .await?; | |
| @@ -895,6 +939,7 @@ impl MnwApiClient { | |||
| 895 | 939 | .http | |
| 896 | 940 | .get(&url) | |
| 897 | 941 | .bearer_auth(&self.service_token) | |
| 942 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 898 | 943 | .query(&[("user_id", user_id), ("range", range)]) | |
| 899 | 944 | .send() | |
| 900 | 945 | .await?; | |
| @@ -909,6 +954,7 @@ impl MnwApiClient { | |||
| 909 | 954 | .http | |
| 910 | 955 | .get(&url) | |
| 911 | 956 | .bearer_auth(&self.service_token) | |
| 957 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 912 | 958 | .query(&[("user_id", user_id)]) | |
| 913 | 959 | .send() | |
| 914 | 960 | .await?; | |
| @@ -923,6 +969,7 @@ impl MnwApiClient { | |||
| 923 | 969 | .http | |
| 924 | 970 | .get(&url) | |
| 925 | 971 | .bearer_auth(&self.service_token) | |
| 972 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 926 | 973 | .query(&[("user_id", user_id)]) | |
| 927 | 974 | .send() | |
| 928 | 975 | .await?; | |
| @@ -945,6 +992,7 @@ impl MnwApiClient { | |||
| 945 | 992 | .http | |
| 946 | 993 | .post(&url) | |
| 947 | 994 | .bearer_auth(&self.service_token) | |
| 995 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 948 | 996 | .json(&serde_json::json!({ | |
| 949 | 997 | "user_id": user_id, | |
| 950 | 998 | "operation": operation, | |
| @@ -978,6 +1026,7 @@ impl MnwApiClient { | |||
| 978 | 1026 | .http | |
| 979 | 1027 | .get(&url) | |
| 980 | 1028 | .bearer_auth(&self.service_token) | |
| 1029 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 981 | 1030 | .query(&[("user_id", user_id)]) | |
| 982 | 1031 | .send() | |
| 983 | 1032 | .await?; | |
| @@ -990,6 +1039,7 @@ impl MnwApiClient { | |||
| 990 | 1039 | pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 991 | 1040 | let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id); | |
| 992 | 1041 | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1042 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 993 | 1043 | .query(&[("user_id", user_id)]).send().await?; | |
| 994 | 1044 | json_response(resp, "list_item_tags").await | |
| 995 | 1045 | } | |
| @@ -997,6 +1047,7 @@ impl MnwApiClient { | |||
| 997 | 1047 | pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 998 | 1048 | let url = format!("{}/api/internal/tags/search", self.base_url); | |
| 999 | 1049 | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1050 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1000 | 1051 | .query(&[("q", query)]).send().await?; | |
| 1001 | 1052 | json_response(resp, "search_tags").await | |
| 1002 | 1053 | } | |
| @@ -1004,6 +1055,7 @@ impl MnwApiClient { | |||
| 1004 | 1055 | pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 1005 | 1056 | let url = format!("{}/api/internal/creator/items/tags", self.base_url); | |
| 1006 | 1057 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1058 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1007 | 1059 | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 1008 | 1060 | .send().await?; | |
| 1009 | 1061 | empty_response(resp, "add_item_tag").await | |
| @@ -1012,6 +1064,7 @@ impl MnwApiClient { | |||
| 1012 | 1064 | pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> { | |
| 1013 | 1065 | let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url); | |
| 1014 | 1066 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1067 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1015 | 1068 | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 1016 | 1069 | .send().await?; | |
| 1017 | 1070 | empty_response(resp, "remove_item_tag").await | |
| @@ -1022,6 +1075,7 @@ impl MnwApiClient { | |||
| 1022 | 1075 | pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> { | |
| 1023 | 1076 | let url = format!("{}/api/internal/creator/broadcast", self.base_url); | |
| 1024 | 1077 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1078 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1025 | 1079 | .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body})) | |
| 1026 | 1080 | .send().await?; | |
| 1027 | 1081 | json_response(resp, "send_broadcast").await | |
| @@ -1032,6 +1086,7 @@ impl MnwApiClient { | |||
| 1032 | 1086 | pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> { | |
| 1033 | 1087 | let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id); | |
| 1034 | 1088 | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1089 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1035 | 1090 | .query(&[("user_id", user_id)]).send().await?; | |
| 1036 | 1091 | json_response(resp, "list_tiers").await | |
| 1037 | 1092 | } | |
| @@ -1041,6 +1096,7 @@ impl MnwApiClient { | |||
| 1041 | 1096 | pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> { | |
| 1042 | 1097 | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1043 | 1098 | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1099 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1044 | 1100 | .query(&[("user_id", user_id)]).send().await?; | |
| 1045 | 1101 | json_response(resp, "list_collections").await | |
| 1046 | 1102 | } | |
| @@ -1048,6 +1104,7 @@ impl MnwApiClient { | |||
| 1048 | 1104 | pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> { | |
| 1049 | 1105 | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 1050 | 1106 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1107 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1051 | 1108 | .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title})) | |
| 1052 | 1109 | .send().await?; | |
| 1053 | 1110 | json_response(resp, "create_collection").await | |
| @@ -1056,6 +1113,7 @@ impl MnwApiClient { | |||
| 1056 | 1113 | pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> { | |
| 1057 | 1114 | let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id); | |
| 1058 | 1115 | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1116 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1059 | 1117 | .query(&[("user_id", user_id)]).send().await?; | |
| 1060 | 1118 | empty_response(resp, "delete_collection").await | |
| 1061 | 1119 | } | |
| @@ -1065,6 +1123,7 @@ impl MnwApiClient { | |||
| 1065 | 1123 | pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> { | |
| 1066 | 1124 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1067 | 1125 | let resp = self.http.get(&url).bearer_auth(&self.service_token) | |
| 1126 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1068 | 1127 | .query(&[("user_id", user_id)]).send().await?; | |
| 1069 | 1128 | let val: serde_json::Value = json_response(resp, "get_domain").await?; | |
| 1070 | 1129 | if val.is_null() { return Ok(None); } | |
| @@ -1074,6 +1133,7 @@ impl MnwApiClient { | |||
| 1074 | 1133 | pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> { | |
| 1075 | 1134 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1076 | 1135 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1136 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1077 | 1137 | .json(&serde_json::json!({"user_id": user_id, "domain": domain})) | |
| 1078 | 1138 | .send().await?; | |
| 1079 | 1139 | json_response(resp, "add_domain").await | |
| @@ -1082,6 +1142,7 @@ impl MnwApiClient { | |||
| 1082 | 1142 | pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> { | |
| 1083 | 1143 | let url = format!("{}/api/internal/creator/domain/verify", self.base_url); | |
| 1084 | 1144 | let resp = self.http.post(&url).bearer_auth(&self.service_token) | |
| 1145 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1085 | 1146 | .query(&[("user_id", user_id)]).send().await?; | |
| 1086 | 1147 | json_response(resp, "verify_domain").await | |
| 1087 | 1148 | } | |
| @@ -1089,6 +1150,7 @@ impl MnwApiClient { | |||
| 1089 | 1150 | pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> { | |
| 1090 | 1151 | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 1091 | 1152 | let resp = self.http.delete(&url).bearer_auth(&self.service_token) | |
| 1153 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 1092 | 1154 | .query(&[("user_id", user_id)]).send().await?; | |
| 1093 | 1155 | empty_response(resp, "remove_domain").await | |
| 1094 | 1156 | } |
| @@ -107,6 +107,10 @@ impl russh::server::Handler for MnwHandler { | |||
| 107 | 107 | partial_success: false, | |
| 108 | 108 | }); | |
| 109 | 109 | } | |
| 110 | + | // Forward the SSH-authenticated actor assertion on this session's | |
| 111 | + | // internal calls so the server verifies identity from the token, | |
| 112 | + | // not a caller-supplied user_id. | |
| 113 | + | self.api.set_actor_token(info.actor_token.clone()); | |
| 110 | 114 | self.user = Some(info); | |
| 111 | 115 | Ok(Auth::Accept) | |
| 112 | 116 | } |
| @@ -425,6 +425,54 @@ impl FromRequestParts<crate::AppState> for ServiceAuth { | |||
| 425 | 425 | } | |
| 426 | 426 | } | |
| 427 | 427 | ||
| 428 | + | /// The SSH-authenticated user identity behind an internal-API request. | |
| 429 | + | /// | |
| 430 | + | /// Sourced from the `X-MNW-Actor` header, a signed assertion the server mints | |
| 431 | + | /// during `ssh-key-lookup` (after authenticating the user by SSH key) and the | |
| 432 | + | /// CLI forwards. Because the assertion is keyed by the server-only | |
| 433 | + | /// `signing_secret`, a leaked `ServiceAuth` token cannot forge one for another | |
| 434 | + | /// user — so internal handlers derive identity from this, never from a | |
| 435 | + | /// caller-supplied `user_id` field. Handlers use `actor.user_id()` for scoping | |
| 436 | + | /// and `actor.ensure_owns(resource.user_id)?` for ownership checks. | |
| 437 | + | pub struct InternalActor(pub UserId); | |
| 438 | + | ||
| 439 | + | impl InternalActor { | |
| 440 | + | pub fn user_id(&self) -> UserId { | |
| 441 | + | self.0 | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /// Reject with 403 unless the asserted identity owns the resource. | |
| 445 | + | pub fn ensure_owns(&self, owner: UserId) -> Result<(), AppError> { | |
| 446 | + | if self.0 == owner { | |
| 447 | + | Ok(()) | |
| 448 | + | } else { | |
| 449 | + | Err(AppError::Forbidden) | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | impl FromRequestParts<crate::AppState> for InternalActor { | |
| 455 | + | type Rejection = AppError; | |
| 456 | + | ||
| 457 | + | async fn from_request_parts( | |
| 458 | + | parts: &mut Parts, | |
| 459 | + | state: &crate::AppState, | |
| 460 | + | ) -> Result<Self, Self::Rejection> { | |
| 461 | + | let token = parts | |
| 462 | + | .headers | |
| 463 | + | .get("x-mnw-actor") | |
| 464 | + | .and_then(|v| v.to_str().ok()) | |
| 465 | + | .ok_or(AppError::Unauthorized)?; | |
| 466 | + | ||
| 467 | + | let now = chrono::Utc::now().timestamp(); | |
| 468 | + | let user_id = | |
| 469 | + | crate::crypto::verify_internal_actor_token(token, &state.config.signing_secret, now) | |
| 470 | + | .ok_or(AppError::Unauthorized)?; | |
| 471 | + | ||
| 472 | + | Ok(InternalActor(user_id)) | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | ||
| 428 | 476 | /// Hash a password using Argon2id. | |
| 429 | 477 | /// | |
| 430 | 478 | /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms). |
| @@ -13,6 +13,10 @@ pub const DB_MAX_LIFETIME_SECS: u64 = 1800; | |||
| 13 | 13 | /// Prune idle connections after 10 minutes. | |
| 14 | 14 | pub const DB_IDLE_TIMEOUT_SECS: u64 = 600; | |
| 15 | 15 | ||
| 16 | + | /// Lifetime of an internal-API actor assertion, minted at `ssh-key-lookup` and | |
| 17 | + | /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session. | |
| 18 | + | pub const INTERNAL_ACTOR_TTL_SECS: i64 = 86_400; | |
| 19 | + | ||
| 16 | 20 | // -- Sessions -- | |
| 17 | 21 | pub const SESSION_EXPIRY_DAYS: i64 = 7; | |
| 18 | 22 | /// Skip DB touch if validated within this window. Doubles as the upper bound on |
| @@ -171,6 +171,58 @@ pub fn invite_code_hash(code: &str) -> String { | |||
| 171 | 171 | sha256_hex(code) | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | /// Domain-separation prefix for the internal-API actor assertion HMAC. | |
| 175 | + | const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1"; | |
| 176 | + | ||
| 177 | + | /// Mint a signed actor assertion binding `user_id` for the internal API, valid | |
| 178 | + | /// until `expiry_unix`. Format: `{user_id}.{expiry}.{hmac_hex}`, HMAC-SHA256 | |
| 179 | + | /// over `internal-actor:v1:{user_id}:{expiry}` keyed by `signing_secret`. | |
| 180 | + | /// | |
| 181 | + | /// The server mints this during `ssh-key-lookup` (after authenticating the user | |
| 182 | + | /// by SSH key) and the CLI forwards it on internal calls. Because the key is the | |
| 183 | + | /// server-only `signing_secret` (never held by the CLI or embedded in the shared | |
| 184 | + | /// `cli_service_token`), a leaked service token alone cannot forge an assertion | |
| 185 | + | /// for another user — so it cannot act as an arbitrary user. | |
| 186 | + | pub fn mint_internal_actor_token( | |
| 187 | + | user_id: crate::db::UserId, | |
| 188 | + | expiry_unix: i64, | |
| 189 | + | signing_secret: &str, | |
| 190 | + | ) -> String { | |
| 191 | + | use hmac::{Hmac, Mac}; | |
| 192 | + | use sha2::Sha256; | |
| 193 | + | let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry_unix}"); | |
| 194 | + | let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes()) | |
| 195 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 196 | + | mac.update(message.as_bytes()); | |
| 197 | + | format!("{user_id}.{expiry_unix}.{}", hex::encode(mac.finalize().into_bytes())) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// Verify an actor assertion and return the asserted `UserId` if the signature | |
| 201 | + | /// is valid (constant-time) and the token has not expired at `now_unix`. | |
| 202 | + | pub fn verify_internal_actor_token( | |
| 203 | + | token: &str, | |
| 204 | + | signing_secret: &str, | |
| 205 | + | now_unix: i64, | |
| 206 | + | ) -> Option<crate::db::UserId> { | |
| 207 | + | use hmac::{Hmac, Mac}; | |
| 208 | + | use sha2::Sha256; | |
| 209 | + | ||
| 210 | + | let (user_part, rest) = token.split_once('.')?; | |
| 211 | + | let (expiry_part, sig_hex) = rest.split_once('.')?; | |
| 212 | + | let user_id = crate::db::UserId::from_uuid(user_part.parse().ok()?); | |
| 213 | + | let expiry: i64 = expiry_part.parse().ok()?; | |
| 214 | + | if expiry <= now_unix { | |
| 215 | + | return None; | |
| 216 | + | } | |
| 217 | + | let sig = hex::decode(sig_hex).ok()?; | |
| 218 | + | let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry}"); | |
| 219 | + | let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes()) | |
| 220 | + | .expect("HMAC-SHA256 accepts any key length"); | |
| 221 | + | mac.update(message.as_bytes()); | |
| 222 | + | mac.verify_slice(&sig).ok()?; | |
| 223 | + | Some(user_id) | |
| 224 | + | } | |
| 225 | + | ||
| 174 | 226 | /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`. | |
| 175 | 227 | fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String { | |
| 176 | 228 | use hmac::{Hmac, Mac}; | |
| @@ -218,6 +270,46 @@ pub fn verify_feed_signature( | |||
| 218 | 270 | mod tests { | |
| 219 | 271 | use super::*; | |
| 220 | 272 | ||
| 273 | + | // ── Internal-API actor assertions ── | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn internal_actor_token_round_trips() { | |
| 277 | + | let uid = crate::db::UserId::new(); | |
| 278 | + | let secret = "a-stable-signing-secret-at-least-32c"; | |
| 279 | + | let tok = mint_internal_actor_token(uid, 10_000_000_000, secret); | |
| 280 | + | assert_eq!(verify_internal_actor_token(&tok, secret, 1_000), Some(uid)); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn internal_actor_token_rejects_expired() { | |
| 285 | + | let uid = crate::db::UserId::new(); | |
| 286 | + | let secret = "a-stable-signing-secret-at-least-32c"; | |
| 287 | + | let tok = mint_internal_actor_token(uid, 1_000, secret); | |
| 288 | + | assert_eq!(verify_internal_actor_token(&tok, secret, 2_000), None); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn internal_actor_token_rejects_wrong_secret() { | |
| 293 | + | let uid = crate::db::UserId::new(); | |
| 294 | + | let tok = mint_internal_actor_token(uid, 10_000_000_000, "secret-one-that-is-long-enough!!"); | |
| 295 | + | assert_eq!( | |
| 296 | + | verify_internal_actor_token(&tok, "secret-two-that-is-long-enough!!", 1_000), | |
| 297 | + | None | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn internal_actor_token_rejects_tampered_user() { | |
| 303 | + | let uid = crate::db::UserId::new(); | |
| 304 | + | let other = crate::db::UserId::new(); | |
| 305 | + | let secret = "a-stable-signing-secret-at-least-32c"; | |
| 306 | + | let tok = mint_internal_actor_token(uid, 10_000_000_000, secret); | |
| 307 | + | // Swap the user_id component; the signature no longer matches. | |
| 308 | + | let rest = tok.split_once('.').unwrap().1; | |
| 309 | + | let forged = format!("{other}.{rest}"); | |
| 310 | + | assert_eq!(verify_internal_actor_token(&forged, secret, 1_000), None); | |
| 311 | + | } | |
| 312 | + | ||
| 221 | 313 | // ── TOTP secret encryption at rest ── | |
| 222 | 314 | ||
| 223 | 315 | #[test] |
| @@ -2,27 +2,26 @@ | |||
| 2 | 2 | //! collections, custom domains). | |
| 3 | 3 | ||
| 4 | 4 | use axum::extract::State; | |
| 5 | + | use crate::auth::InternalActor; | |
| 5 | 6 | use axum::response::IntoResponse; | |
| 6 | 7 | use axum::Json; | |
| 7 | 8 | use serde::{Deserialize, Serialize}; | |
| 8 | 9 | ||
| 9 | 10 | use crate::auth::ServiceAuth; | |
| 10 | 11 | use crate::constants; | |
| 11 | - | use crate::db::{self, CollectionId, ItemId, ProjectId, Slug, UserId}; | |
| 12 | + | use crate::db::{self, CollectionId, ItemId, ProjectId, Slug}; | |
| 12 | 13 | use crate::error::{AppError, Result, ResultExt}; | |
| 13 | 14 | use crate::AppState; | |
| 14 | 15 | ||
| 15 | 16 | /// User ID query parameter shared by all internal endpoints. | |
| 16 | 17 | #[derive(Deserialize)] | |
| 17 | 18 | pub(super) struct UserIdParam { | |
| 18 | - | pub user_id: UserId, | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | // ── Tags ── | |
| 22 | 22 | ||
| 23 | 23 | #[derive(Deserialize)] | |
| 24 | 24 | pub(super) struct TagItemRequest { | |
| 25 | - | user_id: UserId, | |
| 26 | 25 | item_id: ItemId, | |
| 27 | 26 | tag_id: String, | |
| 28 | 27 | } | |
| @@ -39,9 +38,10 @@ struct TagView { | |||
| 39 | 38 | #[tracing::instrument(skip_all, name = "internal::list_item_tags")] | |
| 40 | 39 | pub(super) async fn list_item_tags( | |
| 41 | 40 | State(state): State<AppState>, | |
| 41 | + | actor: InternalActor, | |
| 42 | 42 | _auth: ServiceAuth, | |
| 43 | 43 | axum::extract::Path(item_id): axum::extract::Path<ItemId>, | |
| 44 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 44 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 45 | 45 | ) -> Result<impl IntoResponse> { | |
| 46 | 46 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 47 | 47 | .await? | |
| @@ -49,7 +49,7 @@ pub(super) async fn list_item_tags( | |||
| 49 | 49 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 50 | 50 | .await? | |
| 51 | 51 | .ok_or(AppError::NotFound)?; | |
| 52 | - | if project.user_id != q.user_id { | |
| 52 | + | if project.user_id != actor.user_id() { | |
| 53 | 53 | return Err(AppError::Forbidden); | |
| 54 | 54 | } | |
| 55 | 55 | ||
| @@ -71,6 +71,7 @@ pub(super) async fn list_item_tags( | |||
| 71 | 71 | #[tracing::instrument(skip_all, name = "internal::add_item_tag")] | |
| 72 | 72 | pub(super) async fn add_item_tag( | |
| 73 | 73 | State(state): State<AppState>, | |
| 74 | + | actor: InternalActor, | |
| 74 | 75 | _auth: ServiceAuth, | |
| 75 | 76 | Json(req): Json<TagItemRequest>, | |
| 76 | 77 | ) -> Result<impl IntoResponse> { | |
| @@ -80,7 +81,7 @@ pub(super) async fn add_item_tag( | |||
| 80 | 81 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 81 | 82 | .await? | |
| 82 | 83 | .ok_or(AppError::NotFound)?; | |
| 83 | - | if project.user_id != req.user_id { | |
| 84 | + | if project.user_id != actor.user_id() { | |
| 84 | 85 | return Err(AppError::Forbidden); | |
| 85 | 86 | } | |
| 86 | 87 | ||
| @@ -101,6 +102,7 @@ pub(super) async fn add_item_tag( | |||
| 101 | 102 | #[tracing::instrument(skip_all, name = "internal::remove_item_tag")] | |
| 102 | 103 | pub(super) async fn remove_item_tag( | |
| 103 | 104 | State(state): State<AppState>, | |
| 105 | + | actor: InternalActor, | |
| 104 | 106 | _auth: ServiceAuth, | |
| 105 | 107 | Json(req): Json<TagItemRequest>, | |
| 106 | 108 | ) -> Result<impl IntoResponse> { | |
| @@ -110,7 +112,7 @@ pub(super) async fn remove_item_tag( | |||
| 110 | 112 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 111 | 113 | .await? | |
| 112 | 114 | .ok_or(AppError::NotFound)?; | |
| 113 | - | if project.user_id != req.user_id { | |
| 115 | + | if project.user_id != actor.user_id() { | |
| 114 | 116 | return Err(AppError::Forbidden); | |
| 115 | 117 | } | |
| 116 | 118 | ||
| @@ -152,7 +154,6 @@ pub(super) async fn search_tags( | |||
| 152 | 154 | ||
| 153 | 155 | #[derive(Deserialize)] | |
| 154 | 156 | pub(super) struct BroadcastRequest { | |
| 155 | - | user_id: UserId, | |
| 156 | 157 | subject: String, | |
| 157 | 158 | body: String, | |
| 158 | 159 | } | |
| @@ -161,6 +162,7 @@ pub(super) struct BroadcastRequest { | |||
| 161 | 162 | #[tracing::instrument(skip_all, name = "internal::send_broadcast")] | |
| 162 | 163 | pub(super) async fn send_broadcast( | |
| 163 | 164 | State(state): State<AppState>, | |
| 165 | + | actor: InternalActor, | |
| 164 | 166 | _auth: ServiceAuth, | |
| 165 | 167 | Json(req): Json<BroadcastRequest>, | |
| 166 | 168 | ) -> Result<impl IntoResponse> { | |
| @@ -171,7 +173,7 @@ pub(super) async fn send_broadcast( | |||
| 171 | 173 | return Err(AppError::validation("Body must be 1-5000 characters".to_string())); | |
| 172 | 174 | } | |
| 173 | 175 | ||
| 174 | - | let db_user = db::users::get_user_by_id(&state.db, req.user_id) | |
| 176 | + | let db_user = db::users::get_user_by_id(&state.db, actor.user_id()) | |
| 175 | 177 | .await? | |
| 176 | 178 | .ok_or(AppError::NotFound)?; | |
| 177 | 179 | ||
| @@ -179,7 +181,7 @@ pub(super) async fn send_broadcast( | |||
| 179 | 181 | return Err(AppError::Forbidden); | |
| 180 | 182 | } | |
| 181 | 183 | ||
| 182 | - | let set = db::users::try_set_broadcast_at(&state.db, req.user_id).await?; | |
| 184 | + | let set = db::users::try_set_broadcast_at(&state.db, actor.user_id()).await?; | |
| 183 | 185 | if !set { | |
| 184 | 186 | return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string())); | |
| 185 | 187 | } | |
| @@ -187,12 +189,12 @@ pub(super) async fn send_broadcast( | |||
| 187 | 189 | // Enforce the broadcast recipient cap at the type level — the same | |
| 188 | 190 | // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs) | |
| 189 | 191 | // uses, so the two handlers can no longer drift on the cap check. | |
| 190 | - | let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?; | |
| 192 | + | let followers = db::follows::get_follower_emails(&state.db, actor.user_id()).await?; | |
| 191 | 193 | let recipients = match crate::email::BoundedRecipients::new(followers) { | |
| 192 | 194 | Ok(r) => r, | |
| 193 | 195 | Err(count) => { | |
| 194 | 196 | // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted. | |
| 195 | - | let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await; | |
| 197 | + | let _ = db::users::clear_broadcast_at(&state.db, actor.user_id()).await; | |
| 196 | 198 | return Err(AppError::validation(format!( | |
| 197 | 199 | "Broadcast would reach {count} followers, above the per-send limit of 10,000. \ | |
| 198 | 200 | Email info@makenot.work to lift the cap for your account." | |
| @@ -208,7 +210,7 @@ pub(super) async fn send_broadcast( | |||
| 208 | 210 | .to_string(); | |
| 209 | 211 | let host_url = state.config.host_url.clone(); | |
| 210 | 212 | let signing_secret = state.config.signing_secret.clone(); | |
| 211 | - | let creator_id = req.user_id; | |
| 213 | + | let creator_id = actor.user_id(); | |
| 212 | 214 | let subject = req.subject.clone(); | |
| 213 | 215 | let body = req.body.clone(); | |
| 214 | 216 | let email_client = state.email.clone(); | |
| @@ -273,14 +275,15 @@ struct TierView { | |||
| 273 | 275 | #[tracing::instrument(skip_all, name = "internal::list_tiers")] | |
| 274 | 276 | pub(super) async fn list_tiers( | |
| 275 | 277 | State(state): State<AppState>, | |
| 278 | + | actor: InternalActor, | |
| 276 | 279 | _auth: ServiceAuth, | |
| 277 | 280 | axum::extract::Path(project_id): axum::extract::Path<ProjectId>, | |
| 278 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 281 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 279 | 282 | ) -> Result<impl IntoResponse> { | |
| 280 | 283 | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 281 | 284 | .await? | |
| 282 | 285 | .ok_or(AppError::NotFound)?; | |
| 283 | - | if project.user_id != q.user_id { | |
| 286 | + | if project.user_id != actor.user_id() { | |
| 284 | 287 | return Err(AppError::Forbidden); | |
| 285 | 288 | } | |
| 286 | 289 | ||
| @@ -303,7 +306,6 @@ pub(super) async fn list_tiers( | |||
| 303 | 306 | ||
| 304 | 307 | #[derive(Deserialize)] | |
| 305 | 308 | pub(super) struct CreateCollectionRequest { | |
| 306 | - | user_id: UserId, | |
| 307 | 309 | slug: String, | |
| 308 | 310 | title: String, | |
| 309 | 311 | description: Option<String>, | |
| @@ -324,10 +326,11 @@ struct CollectionView { | |||
| 324 | 326 | #[tracing::instrument(skip_all, name = "internal::list_collections")] | |
| 325 | 327 | pub(super) async fn list_collections( | |
| 326 | 328 | State(state): State<AppState>, | |
| 329 | + | actor: InternalActor, | |
| 327 | 330 | _auth: ServiceAuth, | |
| 328 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 331 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 329 | 332 | ) -> Result<impl IntoResponse> { | |
| 330 | - | let collections = db::collections::get_collections_by_user(&state.db, q.user_id).await?; | |
| 333 | + | let collections = db::collections::get_collections_by_user(&state.db, actor.user_id()).await?; | |
| 331 | 334 | let views: Vec<CollectionView> = collections | |
| 332 | 335 | .iter() | |
| 333 | 336 | .map(|c| CollectionView { | |
| @@ -347,6 +350,7 @@ pub(super) async fn list_collections( | |||
| 347 | 350 | #[tracing::instrument(skip_all, name = "internal::create_collection")] | |
| 348 | 351 | pub(super) async fn create_collection( | |
| 349 | 352 | State(state): State<AppState>, | |
| 353 | + | actor: InternalActor, | |
| 350 | 354 | _auth: ServiceAuth, | |
| 351 | 355 | Json(req): Json<CreateCollectionRequest>, | |
| 352 | 356 | ) -> Result<impl IntoResponse> { | |
| @@ -355,7 +359,7 @@ pub(super) async fn create_collection( | |||
| 355 | 359 | ||
| 356 | 360 | let collection = db::collections::create_collection( | |
| 357 | 361 | &state.db, | |
| 358 | - | req.user_id, | |
| 362 | + | actor.user_id(), | |
| 359 | 363 | &slug, | |
| 360 | 364 | &req.title, | |
| 361 | 365 | req.description.as_deref(), | |
| @@ -373,14 +377,15 @@ pub(super) async fn create_collection( | |||
| 373 | 377 | #[tracing::instrument(skip_all, name = "internal::delete_collection")] | |
| 374 | 378 | pub(super) async fn delete_collection( | |
| 375 | 379 | State(state): State<AppState>, | |
| 380 | + | actor: InternalActor, | |
| 376 | 381 | _auth: ServiceAuth, | |
| 377 | 382 | axum::extract::Path(collection_id): axum::extract::Path<CollectionId>, | |
| 378 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 383 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 379 | 384 | ) -> Result<impl IntoResponse> { | |
| 380 | 385 | let collection = db::collections::get_collection_by_id(&state.db, collection_id) | |
| 381 | 386 | .await? | |
| 382 | 387 | .ok_or(AppError::NotFound)?; | |
| 383 | - | if collection.user_id != q.user_id { | |
| 388 | + | if collection.user_id != actor.user_id() { | |
| 384 | 389 | return Err(AppError::Forbidden); | |
| 385 | 390 | } | |
| 386 | 391 | ||
| @@ -393,7 +398,6 @@ pub(super) async fn delete_collection( | |||
| 393 | 398 | ||
| 394 | 399 | #[derive(Deserialize)] | |
| 395 | 400 | pub(super) struct AddDomainRequest { | |
| 396 | - | user_id: UserId, | |
| 397 | 401 | domain: String, | |
| 398 | 402 | } | |
| 399 | 403 | ||
| @@ -401,10 +405,11 @@ pub(super) struct AddDomainRequest { | |||
| 401 | 405 | #[tracing::instrument(skip_all, name = "internal::get_domain")] | |
| 402 | 406 | pub(super) async fn get_domain( | |
| 403 | 407 | State(state): State<AppState>, | |
| 408 | + | actor: InternalActor, | |
| 404 | 409 | _auth: ServiceAuth, | |
| 405 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 410 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 406 | 411 | ) -> Result<impl IntoResponse> { | |
| 407 | - | let domain = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id).await?; | |
| 412 | + | let domain = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id()).await?; | |
| 408 | 413 | match domain { | |
| 409 | 414 | Some(d) => Ok(Json(serde_json::json!({ | |
| 410 | 415 | "id": d.id.to_string(), | |
| @@ -420,6 +425,7 @@ pub(super) async fn get_domain( | |||
| 420 | 425 | #[tracing::instrument(skip_all, name = "internal::add_domain")] | |
| 421 | 426 | pub(super) async fn add_domain( | |
| 422 | 427 | State(state): State<AppState>, | |
| 428 | + | actor: InternalActor, | |
| 423 | 429 | _auth: ServiceAuth, | |
| 424 | 430 | Json(req): Json<AddDomainRequest>, | |
| 425 | 431 | ) -> Result<impl IntoResponse> { | |
| @@ -432,7 +438,7 @@ pub(super) async fn add_domain( | |||
| 432 | 438 | } | |
| 433 | 439 | ||
| 434 | 440 | let token = generate_verification_token(); | |
| 435 | - | let record = db::custom_domains::create_custom_domain(&state.db, req.user_id, &domain, &token).await?; | |
| 441 | + | let record = db::custom_domains::create_custom_domain(&state.db, actor.user_id(), &domain, &token).await?; | |
| 436 | 442 | ||
| 437 | 443 | Ok(Json(serde_json::json!({ | |
| 438 | 444 | "id": record.id.to_string(), | |
| @@ -447,10 +453,11 @@ pub(super) async fn add_domain( | |||
| 447 | 453 | #[tracing::instrument(skip_all, name = "internal::verify_domain")] | |
| 448 | 454 | pub(super) async fn verify_domain( | |
| 449 | 455 | State(state): State<AppState>, | |
| 456 | + | actor: InternalActor, | |
| 450 | 457 | _auth: ServiceAuth, | |
| 451 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 458 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 452 | 459 | ) -> Result<impl IntoResponse> { | |
| 453 | - | let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id) | |
| 460 | + | let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id()) | |
| 454 | 461 | .await? | |
| 455 | 462 | .ok_or(AppError::NotFound)?; | |
| 456 | 463 | ||
| @@ -489,7 +496,7 @@ pub(super) async fn verify_domain( | |||
| 489 | 496 | ||
| 490 | 497 | if verified { | |
| 491 | 498 | db::custom_domains::mark_domain_verified(&state.db, record.id).await?; | |
| 492 | - | state.domain_cache.insert(record.domain.clone(), q.user_id); | |
| 499 | + | state.domain_cache.insert(record.domain.clone(), actor.user_id()); | |
| 493 | 500 | Ok(Json(serde_json::json!({"verified": true, "message": "Domain verified"}))) | |
| 494 | 501 | } else { | |
| 495 | 502 | Ok(Json(serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)}))) | |
| @@ -500,14 +507,15 @@ pub(super) async fn verify_domain( | |||
| 500 | 507 | #[tracing::instrument(skip_all, name = "internal::remove_domain")] | |
| 501 | 508 | pub(super) async fn remove_domain( | |
| 502 | 509 | State(state): State<AppState>, | |
| 510 | + | actor: InternalActor, | |
| 503 | 511 | _auth: ServiceAuth, | |
| 504 | - | axum::extract::Query(q): axum::extract::Query<UserIdParam>, | |
| 512 | + | axum::extract::Query(_q): axum::extract::Query<UserIdParam>, | |
| 505 | 513 | ) -> Result<impl IntoResponse> { | |
| 506 | - | let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id) | |
| 514 | + | let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id()) | |
| 507 | 515 | .await? | |
| 508 | 516 | .ok_or(AppError::NotFound)?; | |
| 509 | 517 | ||
| 510 | - | db::custom_domains::delete_custom_domain(&state.db, record.id, q.user_id).await?; | |
| 518 | + | db::custom_domains::delete_custom_domain(&state.db, record.id, actor.user_id()).await?; | |
| 511 | 519 | state.domain_cache.remove(&record.domain); | |
| 512 | 520 | ||
| 513 | 521 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| @@ -551,7 +559,6 @@ fn slug_from_title(title: &str) -> String { | |||
| 551 | 559 | ||
| 552 | 560 | #[derive(Deserialize)] | |
| 553 | 561 | pub(super) struct CreateProjectRequest { | |
| 554 | - | user_id: UserId, | |
| 555 | 562 | title: String, | |
| 556 | 563 | project_type: String, | |
| 557 | 564 | description: Option<String>, | |
| @@ -569,11 +576,12 @@ struct CreateProjectResponse { | |||
| 569 | 576 | #[tracing::instrument(skip_all, name = "internal::create_project")] | |
| 570 | 577 | pub(super) async fn create_project( | |
| 571 | 578 | State(state): State<AppState>, | |
| 579 | + | actor: InternalActor, | |
| 572 | 580 | _auth: ServiceAuth, | |
| 573 | 581 | Json(req): Json<CreateProjectRequest>, | |
| 574 | 582 | ) -> Result<impl IntoResponse> { | |
| 575 | 583 | // Verify user can create projects | |
| 576 | - | let user = db::users::get_user_by_id(&state.db, req.user_id) | |
| 584 | + | let user = db::users::get_user_by_id(&state.db, actor.user_id()) | |
| 577 | 585 | .await? | |
| 578 | 586 | .ok_or(AppError::NotFound)?; | |
| 579 | 587 | ||
| @@ -590,7 +598,7 @@ pub(super) async fn create_project( | |||
| 590 | 598 | ||
| 591 | 599 | let project = db::projects::create_project( | |
| 592 | 600 | &state.db, | |
| 593 | - | req.user_id, | |
| 601 | + | actor.user_id(), | |
| 594 | 602 | &slug, | |
| 595 | 603 | &req.title, | |
| 596 | 604 | req.description.as_deref(), |
| @@ -6,12 +6,13 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::auth::InternalActor; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::ServiceAuth, | |
| 12 | 13 | db::{ | |
| 13 | 14 | self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, PromoCodeId, | |
| 14 | - | ProjectId, Slug, UserId, | |
| 15 | + | ProjectId, Slug, | |
| 15 | 16 | }, | |
| 16 | 17 | error::{AppError, Result}, | |
| 17 | 18 | helpers, | |
| @@ -23,17 +24,14 @@ use crate::{ | |||
| 23 | 24 | ||
| 24 | 25 | #[derive(Deserialize)] | |
| 25 | 26 | pub(super) struct UserIdQuery { | |
| 26 | - | user_id: UserId, | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | #[derive(Deserialize)] | |
| 30 | 30 | pub(super) struct ItemUserQuery { | |
| 31 | - | user_id: UserId, | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | 33 | #[derive(Deserialize)] | |
| 35 | 34 | pub(super) struct ProjectUserQuery { | |
| 36 | - | user_id: UserId, | |
| 37 | 35 | } | |
| 38 | 36 | ||
| 39 | 37 | // ── Blog posts ── | |
| @@ -69,14 +67,15 @@ impl BlogPostResponse { | |||
| 69 | 67 | #[tracing::instrument(skip_all, name = "internal::list_blog_posts")] | |
| 70 | 68 | pub(super) async fn list_blog_posts( | |
| 71 | 69 | State(state): State<AppState>, | |
| 70 | + | actor: InternalActor, | |
| 72 | 71 | _auth: ServiceAuth, | |
| 73 | 72 | Path(project_id): Path<ProjectId>, | |
| 74 | - | Query(query): Query<ProjectUserQuery>, | |
| 73 | + | Query(_query): Query<ProjectUserQuery>, | |
| 75 | 74 | ) -> Result<impl IntoResponse> { | |
| 76 | 75 | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 77 | 76 | .await? | |
| 78 | 77 | .ok_or(AppError::NotFound)?; | |
| 79 | - | if project.user_id != query.user_id { | |
| 78 | + | if project.user_id != actor.user_id() { | |
| 80 | 79 | return Err(AppError::Forbidden); | |
| 81 | 80 | } | |
| 82 | 81 | ||
| @@ -88,7 +87,6 @@ pub(super) async fn list_blog_posts( | |||
| 88 | 87 | ||
| 89 | 88 | #[derive(Deserialize)] | |
| 90 | 89 | pub(super) struct CreateBlogPostRequest { | |
| 91 | - | user_id: UserId, | |
| 92 | 90 | project_id: ProjectId, | |
| 93 | 91 | title: String, | |
| 94 | 92 | #[serde(default)] | |
| @@ -106,13 +104,14 @@ pub(super) struct CreateBlogPostRequest { | |||
| 106 | 104 | #[tracing::instrument(skip_all, name = "internal::create_blog_post")] | |
| 107 | 105 | pub(super) async fn create_blog_post( | |
| 108 | 106 | State(state): State<AppState>, | |
| 107 | + | actor: InternalActor, | |
| 109 | 108 | _auth: ServiceAuth, | |
| 110 | 109 | Json(req): Json<CreateBlogPostRequest>, | |
| 111 | 110 | ) -> Result<impl IntoResponse> { | |
| 112 | 111 | let project = db::projects::get_project_by_id(&state.db, req.project_id) | |
| 113 | 112 | .await? | |
| 114 | 113 | .ok_or(AppError::NotFound)?; | |
| 115 | - | if project.user_id != req.user_id { | |
| 114 | + | if project.user_id != actor.user_id() { | |
| 116 | 115 | return Err(AppError::Forbidden); | |
| 117 | 116 | } | |
| 118 | 117 | ||
| @@ -124,7 +123,7 @@ pub(super) async fn create_blog_post( | |||
| 124 | 123 | let base = helpers::slugify(&req.title).to_string(); | |
| 125 | 124 | ||
| 126 | 125 | let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); | |
| 127 | - | let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, req.user_id, cdn_base); | |
| 126 | + | let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, actor.user_id(), cdn_base); | |
| 128 | 127 | ||
| 129 | 128 | // If publish_at is set, create as draft and then set the schedule | |
| 130 | 129 | let publish = if req.publish_at.is_some() { false } else { req.publish }; | |
| @@ -133,7 +132,7 @@ pub(super) async fn create_blog_post( | |||
| 133 | 132 | // code pre-checked existence but did NOT retry the insert, so a concurrent | |
| 134 | 133 | // create racing the same slug surfaced a raw 500 (ultra-fuzz Run #1 UX D1). | |
| 135 | 134 | let pool = &state.db; | |
| 136 | - | let (project_id, user_id) = (req.project_id, req.user_id); | |
| 135 | + | let (project_id, user_id) = (req.project_id, actor.user_id()); | |
| 137 | 136 | let (title_s, body_md_s, body_html_s) = | |
| 138 | 137 | (req.title.as_str(), req.body_markdown.as_str(), body_html.as_str()); | |
| 139 | 138 | let post = crate::helpers::insert_with_unique_slug(&base, |slug| async move { | |
| @@ -171,7 +170,7 @@ pub(super) async fn create_blog_post( | |||
| 171 | 170 | post | |
| 172 | 171 | }; | |
| 173 | 172 | ||
| 174 | - | tracing::info!(user = %req.user_id, post = %post.id, "blog post created via CLI"); | |
| 173 | + | tracing::info!(user = %actor.user_id(), post = %post.id, "blog post created via CLI"); | |
| 175 | 174 | ||
| 176 | 175 | Ok(Json(BlogPostResponse::from_db(&post))) | |
| 177 | 176 | } | |
| @@ -182,9 +181,10 @@ pub(super) async fn create_blog_post( | |||
| 182 | 181 | #[tracing::instrument(skip_all, name = "internal::delete_blog_post")] | |
| 183 | 182 | pub(super) async fn delete_blog_post( | |
| 184 | 183 | State(state): State<AppState>, | |
| 184 | + | actor: InternalActor, | |
| 185 | 185 | _auth: ServiceAuth, | |
| 186 | 186 | Path(post_id): Path<BlogPostId>, | |
| 187 | - | Query(query): Query<ItemUserQuery>, | |
| 187 | + | Query(_query): Query<ItemUserQuery>, | |
| 188 | 188 | ) -> Result<impl IntoResponse> { | |
| 189 | 189 | let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id) | |
| 190 | 190 | .await? | |
| @@ -193,13 +193,13 @@ pub(super) async fn delete_blog_post( | |||
| 193 | 193 | let project = db::projects::get_project_by_id(&state.db, post.project_id) | |
| 194 | 194 | .await? | |
| 195 | 195 | .ok_or(AppError::NotFound)?; | |
| 196 | - | if project.user_id != query.user_id { | |
| 196 | + | if project.user_id != actor.user_id() { | |
| 197 | 197 | return Err(AppError::Forbidden); | |
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | - | db::blog_posts::delete_blog_post(&state.db, post_id, query.user_id).await?; | |
| 200 | + | db::blog_posts::delete_blog_post(&state.db, post_id, actor.user_id()).await?; | |
| 201 | 201 | ||
| 202 | - | tracing::info!(user = %query.user_id, post = %post_id, "blog post deleted via CLI"); | |
| 202 | + | tracing::info!(user = %actor.user_id(), post = %post_id, "blog post deleted via CLI"); | |
| 203 | 203 | ||
| 204 | 204 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 205 | 205 | } | |
| @@ -226,10 +226,11 @@ struct PromoCodeResponse { | |||
| 226 | 226 | #[tracing::instrument(skip_all, name = "internal::list_promo_codes")] | |
| 227 | 227 | pub(super) async fn list_promo_codes( | |
| 228 | 228 | State(state): State<AppState>, | |
| 229 | + | actor: InternalActor, | |
| 229 | 230 | _auth: ServiceAuth, | |
| 230 | - | Query(query): Query<UserIdQuery>, | |
| 231 | + | Query(_query): Query<UserIdQuery>, | |
| 231 | 232 | ) -> Result<impl IntoResponse> { | |
| 232 | - | let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, query.user_id).await?; | |
| 233 | + | let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, actor.user_id()).await?; | |
| 233 | 234 | let data: Vec<PromoCodeResponse> = codes | |
| 234 | 235 | .into_iter() | |
| 235 | 236 | .map(|c| PromoCodeResponse { | |
| @@ -251,7 +252,6 @@ pub(super) async fn list_promo_codes( | |||
| 251 | 252 | ||
| 252 | 253 | #[derive(Deserialize)] | |
| 253 | 254 | pub(super) struct CreatePromoCodeRequest { | |
| 254 | - | user_id: UserId, | |
| 255 | 255 | code: String, | |
| 256 | 256 | #[serde(default = "default_code_purpose")] | |
| 257 | 257 | code_purpose: CodePurpose, | |
| @@ -275,6 +275,7 @@ fn default_code_purpose() -> CodePurpose { | |||
| 275 | 275 | #[tracing::instrument(skip_all, name = "internal::create_promo_code")] | |
| 276 | 276 | pub(super) async fn create_promo_code( | |
| 277 | 277 | State(state): State<AppState>, | |
| 278 | + | actor: InternalActor, | |
| 278 | 279 | _auth: ServiceAuth, | |
| 279 | 280 | Json(req): Json<CreatePromoCodeRequest>, | |
| 280 | 281 | ) -> Result<impl IntoResponse> { | |
| @@ -291,7 +292,7 @@ pub(super) async fn create_promo_code( | |||
| 291 | 292 | let owner = db::items::get_item_owner(&state.db, item_id) | |
| 292 | 293 | .await? | |
| 293 | 294 | .ok_or(AppError::NotFound)?; | |
| 294 | - | if owner != req.user_id { | |
| 295 | + | if owner != actor.user_id() { | |
| 295 | 296 | return Err(AppError::Forbidden); | |
| 296 | 297 | } | |
| 297 | 298 | } | |
| @@ -301,14 +302,14 @@ pub(super) async fn create_promo_code( | |||
| 301 | 302 | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 302 | 303 | .await? | |
| 303 | 304 | .ok_or(AppError::NotFound)?; | |
| 304 | - | if project.user_id != req.user_id { | |
| 305 | + | if project.user_id != actor.user_id() { | |
| 305 | 306 | return Err(AppError::Forbidden); | |
| 306 | 307 | } | |
| 307 | 308 | } | |
| 308 | 309 | ||
| 309 | 310 | let code = db::promo_codes::create_promo_code( | |
| 310 | 311 | &state.db, | |
| 311 | - | req.user_id, | |
| 312 | + | actor.user_id(), | |
| 312 | 313 | &req.code, | |
| 313 | 314 | req.code_purpose, | |
| 314 | 315 | req.discount_type, | |
| @@ -324,7 +325,7 @@ pub(super) async fn create_promo_code( | |||
| 324 | 325 | ) | |
| 325 | 326 | .await?; | |
| 326 | 327 | ||
| 327 | - | tracing::info!(user = %req.user_id, code = %code.code, "promo code created via CLI"); | |
| 328 | + | tracing::info!(user = %actor.user_id(), code = %code.code, "promo code created via CLI"); | |
| 328 | 329 | ||
| 329 | 330 | Ok(Json(PromoCodeResponse { | |
| 330 | 331 | id: code.id, | |
| @@ -346,20 +347,21 @@ pub(super) async fn create_promo_code( | |||
| 346 | 347 | #[tracing::instrument(skip_all, name = "internal::delete_promo_code")] | |
| 347 | 348 | pub(super) async fn delete_promo_code( | |
| 348 | 349 | State(state): State<AppState>, | |
| 350 | + | actor: InternalActor, | |
| 349 | 351 | _auth: ServiceAuth, | |
| 350 | 352 | Path(code_id): Path<PromoCodeId>, | |
| 351 | - | Query(query): Query<UserIdQuery>, | |
| 353 | + | Query(_query): Query<UserIdQuery>, | |
| 352 | 354 | ) -> Result<impl IntoResponse> { | |
| 353 | 355 | let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) | |
| 354 | 356 | .await? | |
| 355 | 357 | .ok_or(AppError::NotFound)?; | |
| 356 | - | if code.creator_id != query.user_id { | |
| 358 | + | if code.creator_id != actor.user_id() { | |
| 357 | 359 | return Err(AppError::Forbidden); | |
| 358 | 360 | } | |
| 359 | 361 | ||
| 360 | 362 | db::promo_codes::delete_promo_code(&state.db, code_id).await?; | |
| 361 | 363 | ||
| 362 | - | tracing::info!(user = %query.user_id, code = %code.code, "promo code deleted via CLI"); | |
| 364 | + | tracing::info!(user = %actor.user_id(), code = %code.code, "promo code deleted via CLI"); | |
| 363 | 365 | ||
| 364 | 366 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 365 | 367 | } | |
| @@ -382,14 +384,15 @@ struct LicenseKeyResponse { | |||
| 382 | 384 | #[tracing::instrument(skip_all, name = "internal::list_license_keys")] | |
| 383 | 385 | pub(super) async fn list_license_keys( | |
| 384 | 386 | State(state): State<AppState>, | |
| 387 | + | actor: InternalActor, | |
| 385 | 388 | _auth: ServiceAuth, | |
| 386 | 389 | Path(item_id): Path<ItemId>, | |
| 387 | - | Query(query): Query<ItemUserQuery>, | |
| 390 | + | Query(_query): Query<ItemUserQuery>, | |
| 388 | 391 | ) -> Result<impl IntoResponse> { | |
| 389 | 392 | let owner = db::items::get_item_owner(&state.db, item_id) | |
| 390 | 393 | .await? | |
| 391 | 394 | .ok_or(AppError::NotFound)?; | |
| 392 | - | if owner != query.user_id { | |
| 395 | + | if owner != actor.user_id() { | |
| 393 | 396 | return Err(AppError::Forbidden); | |
| 394 | 397 | } | |
| 395 | 398 | ||
| @@ -411,7 +414,6 @@ pub(super) async fn list_license_keys( | |||
| 411 | 414 | ||
| 412 | 415 | #[derive(Deserialize)] | |
| 413 | 416 | pub(super) struct GenerateKeyRequest { | |
| 414 | - | user_id: UserId, | |
| 415 | 417 | } | |
| 416 | 418 | ||
| 417 | 419 | /// POST /api/internal/creator/items/{id}/keys | |
| @@ -420,9 +422,10 @@ pub(super) struct GenerateKeyRequest { | |||
| 420 | 422 | #[tracing::instrument(skip_all, name = "internal::generate_license_key")] | |
| 421 | 423 | pub(super) async fn generate_license_key( | |
| 422 | 424 | State(state): State<AppState>, | |
| 425 | + | actor: InternalActor, | |
| 423 | 426 | _auth: ServiceAuth, | |
| 424 | 427 | Path(item_id): Path<ItemId>, | |
| 425 | - | Json(req): Json<GenerateKeyRequest>, | |
| 428 | + | Json(_req): Json<GenerateKeyRequest>, | |
| 426 | 429 | ) -> Result<impl IntoResponse> { | |
| 427 | 430 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 428 | 431 | .await? | |
| @@ -431,7 +434,7 @@ pub(super) async fn generate_license_key( | |||
| 431 | 434 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 432 | 435 | .await? | |
| 433 | 436 | .ok_or(AppError::NotFound)?; | |
| 434 | - | if project.user_id != req.user_id { | |
| 437 | + | if project.user_id != actor.user_id() { | |
| 435 | 438 | return Err(AppError::Forbidden); | |
| 436 | 439 | } | |
| 437 | 440 | ||
| @@ -447,14 +450,14 @@ pub(super) async fn generate_license_key( | |||
| 447 | 450 | let key = db::license_keys::create_license_key( | |
| 448 | 451 | &state.db, | |
| 449 | 452 | item_id, | |
| 450 | - | req.user_id, | |
| 453 | + | actor.user_id(), | |
| 451 | 454 | None, // transaction_id | |
| 452 | 455 | &key_code, | |
| 453 | 456 | max_activations, | |
| 454 | 457 | ) | |
| 455 | 458 | .await?; | |
| 456 | 459 | ||
| 457 | - | tracing::info!(user = %req.user_id, item = %item_id, "license key generated via CLI"); | |
| 460 | + | tracing::info!(user = %actor.user_id(), item = %item_id, "license key generated via CLI"); | |
| 458 | 461 | ||
| 459 | 462 | Ok(Json(LicenseKeyResponse { | |
| 460 | 463 | id: key.id, | |
| @@ -468,7 +471,6 @@ pub(super) async fn generate_license_key( | |||
| 468 | 471 | ||
| 469 | 472 | #[derive(Deserialize)] | |
| 470 | 473 | pub(super) struct RevokeKeyRequest { | |
| 471 | - | user_id: UserId, | |
| 472 | 474 | } | |
| 473 | 475 | ||
| 474 | 476 | /// POST /api/internal/creator/keys/{id}/revoke | |
| @@ -477,9 +479,10 @@ pub(super) struct RevokeKeyRequest { | |||
| 477 | 479 | #[tracing::instrument(skip_all, name = "internal::revoke_license_key")] | |
| 478 | 480 | pub(super) async fn revoke_license_key( | |
| 479 | 481 | State(state): State<AppState>, | |
| 482 | + | actor: InternalActor, | |
| 480 | 483 | _auth: ServiceAuth, | |
| 481 | 484 | Path(key_id): Path<LicenseKeyId>, | |
| 482 | - | Json(req): Json<RevokeKeyRequest>, | |
| 485 | + | Json(_req): Json<RevokeKeyRequest>, | |
| 483 | 486 | ) -> Result<impl IntoResponse> { | |
| 484 | 487 | let key = db::license_keys::get_license_key_by_id_unchecked(&state.db, key_id) | |
| 485 | 488 | .await? | |
| @@ -489,13 +492,13 @@ pub(super) async fn revoke_license_key( | |||
| 489 | 492 | let owner = db::items::get_item_owner(&state.db, key.item_id) | |
| 490 | 493 | .await? | |
| 491 | 494 | .ok_or(AppError::NotFound)?; | |
| 492 | - | if owner != req.user_id { | |
| 495 | + | if owner != actor.user_id() { | |
| 493 | 496 | return Err(AppError::Forbidden); | |
| 494 | 497 | } | |
| 495 | 498 | ||
| 496 | 499 | db::license_keys::revoke_license_key(&state.db, key_id).await?; | |
| 497 | 500 | ||
| 498 | - | tracing::info!(user = %req.user_id, key = %key_id, "license key revoked via CLI"); | |
| 501 | + | tracing::info!(user = %actor.user_id(), key = %key_id, "license key revoked via CLI"); | |
| 499 | 502 | ||
| 500 | 503 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 501 | 504 | } |
| @@ -6,10 +6,11 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::auth::InternalActor; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::ServiceAuth, | |
| 12 | - | db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId, UserId}, | |
| 13 | + | db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId}, | |
| 13 | 14 | error::{AppError, Result}, | |
| 14 | 15 | helpers, | |
| 15 | 16 | AppState, | |
| @@ -19,7 +20,6 @@ use crate::{ | |||
| 19 | 20 | ||
| 20 | 21 | #[derive(Deserialize)] | |
| 21 | 22 | pub(super) struct UserIdQuery { | |
| 22 | - | user_id: UserId, | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | #[derive(Serialize)] | |
| @@ -39,11 +39,12 @@ struct CreatorProject { | |||
| 39 | 39 | #[tracing::instrument(skip_all, name = "internal::creator_projects")] | |
| 40 | 40 | pub(super) async fn creator_projects( | |
| 41 | 41 | State(state): State<AppState>, | |
| 42 | + | actor: InternalActor, | |
| 42 | 43 | _auth: ServiceAuth, | |
| 43 | - | Query(query): Query<UserIdQuery>, | |
| 44 | + | Query(_query): Query<UserIdQuery>, | |
| 44 | 45 | ) -> Result<impl IntoResponse> { | |
| 45 | - | let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?; | |
| 46 | - | let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?; | |
| 46 | + | let projects = db::projects::get_projects_by_user(&state.db, actor.user_id()).await?; | |
| 47 | + | let revenue = db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?; | |
| 47 | 48 | ||
| 48 | 49 | // Build revenue lookup: project_id -> cents | |
| 49 | 50 | let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue | |
| @@ -52,7 +53,7 @@ pub(super) async fn creator_projects( | |||
| 52 | 53 | .collect(); | |
| 53 | 54 | ||
| 54 | 55 | // Count items per project in a single query | |
| 55 | - | let item_counts = db::items::count_items_by_user_projects(&state.db, query.user_id).await?; | |
| 56 | + | let item_counts = db::items::count_items_by_user_projects(&state.db, actor.user_id()).await?; | |
| 56 | 57 | let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect(); | |
| 57 | 58 | ||
| 58 | 59 | let data: Vec<CreatorProject> = projects | |
| @@ -89,15 +90,16 @@ struct CreatorItem { | |||
| 89 | 90 | #[tracing::instrument(skip_all, name = "internal::creator_project_items")] | |
| 90 | 91 | pub(super) async fn creator_project_items( | |
| 91 | 92 | State(state): State<AppState>, | |
| 93 | + | actor: InternalActor, | |
| 92 | 94 | _auth: ServiceAuth, | |
| 93 | 95 | Path(project_id): Path<ProjectId>, | |
| 94 | - | Query(query): Query<UserIdQuery>, | |
| 96 | + | Query(_query): Query<UserIdQuery>, | |
| 95 | 97 | ) -> Result<impl IntoResponse> { | |
| 96 | 98 | // Verify ownership | |
| 97 | 99 | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 98 | 100 | .await? | |
| 99 | 101 | .ok_or(AppError::NotFound)?; | |
| 100 | - | if project.user_id != query.user_id { | |
| 102 | + | if project.user_id != actor.user_id() { | |
| 101 | 103 | return Err(AppError::Forbidden); | |
| 102 | 104 | } | |
| 103 | 105 | ||
| @@ -122,7 +124,6 @@ pub(super) async fn creator_project_items( | |||
| 122 | 124 | ||
| 123 | 125 | #[derive(Deserialize)] | |
| 124 | 126 | pub(super) struct StatsQuery { | |
| 125 | - | user_id: UserId, | |
| 126 | 127 | /// Time range: "7d", "30d", "90d", or "all" | |
| 127 | 128 | #[serde(default = "default_range")] | |
| 128 | 129 | range: String, | |
| @@ -150,6 +151,7 @@ struct CreatorStats { | |||
| 150 | 151 | #[tracing::instrument(skip_all, name = "internal::creator_stats")] | |
| 151 | 152 | pub(super) async fn creator_stats( | |
| 152 | 153 | State(state): State<AppState>, | |
| 154 | + | actor: InternalActor, | |
| 153 | 155 | _auth: ServiceAuth, | |
| 154 | 156 | Query(query): Query<StatsQuery>, | |
| 155 | 157 | ) -> Result<impl IntoResponse> { | |
| @@ -159,10 +161,10 @@ pub(super) async fn creator_stats( | |||
| 159 | 161 | .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?; | |
| 160 | 162 | ||
| 161 | 163 | let comparison = | |
| 162 | - | db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?; | |
| 164 | + | db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?; | |
| 163 | 165 | ||
| 164 | - | let total_projects = db::projects::count_projects_by_user(&state.db, query.user_id).await?; | |
| 165 | - | let total_items = db::items::count_items_by_user(&state.db, query.user_id).await?; | |
| 166 | + | let total_projects = db::projects::count_projects_by_user(&state.db, actor.user_id()).await?; | |
| 167 | + | let total_items = db::items::count_items_by_user(&state.db, actor.user_id()).await?; | |
| 166 | 168 | ||
| 167 | 169 | Ok(Json(CreatorStats { | |
| 168 | 170 | current_revenue_cents: comparison.current_revenue_cents.as_i64(), | |
| @@ -210,6 +212,7 @@ struct AnalyticsResponse { | |||
| 210 | 212 | #[tracing::instrument(skip_all, name = "internal::creator_analytics")] | |
| 211 | 213 | pub(super) async fn creator_analytics( | |
| 212 | 214 | State(state): State<AppState>, | |
| 215 | + | actor: InternalActor, | |
| 213 | 216 | _auth: ServiceAuth, | |
| 214 | 217 | Query(query): Query<StatsQuery>, | |
| 215 | 218 | ) -> Result<impl IntoResponse> { | |
| @@ -219,12 +222,12 @@ pub(super) async fn creator_analytics( | |||
| 219 | 222 | .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?; | |
| 220 | 223 | ||
| 221 | 224 | let buckets = | |
| 222 | - | db::analytics::get_revenue_timeseries(&state.db, query.user_id, None, None, &range) | |
| 225 | + | db::analytics::get_revenue_timeseries(&state.db, actor.user_id(), None, None, &range) | |
| 223 | 226 | .await?; | |
| 224 | 227 | let comparison = | |
| 225 | - | db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?; | |
| 228 | + | db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?; | |
| 226 | 229 | let revenue = | |
| 227 | - | db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?; | |
| 230 | + | db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?; | |
| 228 | 231 | ||
| 229 | 232 | let top_projects: Vec<ProjectRevenueSummary> = revenue | |
| 230 | 233 | .into_iter() | |
| @@ -272,11 +275,12 @@ struct TransactionResponse { | |||
| 272 | 275 | #[tracing::instrument(skip_all, name = "internal::creator_transactions")] | |
| 273 | 276 | pub(super) async fn creator_transactions( | |
| 274 | 277 | State(state): State<AppState>, | |
| 278 | + | actor: InternalActor, | |
| 275 | 279 | _auth: ServiceAuth, | |
| 276 | - | Query(query): Query<UserIdQuery>, | |
| 280 | + | Query(_query): Query<UserIdQuery>, | |
| 277 | 281 | ) -> Result<impl IntoResponse> { | |
| 278 | 282 | let txs = | |
| 279 | - | db::transactions::get_transactions_by_seller(&state.db, query.user_id, Some(100)).await?; | |
| 283 | + | db::transactions::get_transactions_by_seller(&state.db, actor.user_id(), Some(100)).await?; | |
| 280 | 284 | ||
| 281 | 285 | let data: Vec<TransactionResponse> = txs | |
| 282 | 286 | .into_iter() | |
| @@ -301,8 +305,9 @@ pub(super) async fn creator_transactions( | |||
| 301 | 305 | #[tracing::instrument(skip_all, name = "internal::export_sales")] | |
| 302 | 306 | pub(super) async fn export_sales( | |
| 303 | 307 | State(state): State<AppState>, | |
| 308 | + | actor: InternalActor, | |
| 304 | 309 | _auth: ServiceAuth, | |
| 305 | - | Query(query): Query<UserIdQuery>, | |
| 310 | + | Query(_query): Query<UserIdQuery>, | |
| 306 | 311 | ) -> Result<impl IntoResponse> { | |
| 307 | 312 | // Page the read so a whale seller's full history isn't loaded by one | |
| 308 | 313 | // unbounded query; peak memory is one batch plus the accumulating CSV. | |
| @@ -317,7 +322,7 @@ pub(super) async fn export_sales( | |||
| 317 | 322 | loop { | |
| 318 | 323 | let rows = db::transactions::get_seller_transactions_for_export_page( | |
| 319 | 324 | &state.db, | |
| 320 | - | query.user_id, | |
| 325 | + | actor.user_id(), | |
| 321 | 326 | BATCH, | |
| 322 | 327 | offset, | |
| 323 | 328 | ) | |
| @@ -348,7 +353,7 @@ pub(super) async fn export_sales( | |||
| 348 | 353 | } | |
| 349 | 354 | if total >= MAX_ROWS { | |
| 350 | 355 | tracing::warn!( | |
| 351 | - | user_id = %query.user_id, | |
| 356 | + | user_id = %actor.user_id(), | |
| 352 | 357 | rows = total, | |
| 353 | 358 | "internal sales export hit the row ceiling; truncating" | |
| 354 | 359 | ); |
| @@ -6,6 +6,7 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::auth::InternalActor; | |
| 9 | 10 | use std::sync::atomic::Ordering; | |
| 10 | 11 | ||
| 11 | 12 | use crate::{ | |
| @@ -30,6 +31,10 @@ struct SshKeyLookupResponse { | |||
| 30 | 31 | creator_tier: Option<CreatorTier>, | |
| 31 | 32 | can_create_projects: bool, | |
| 32 | 33 | suspended: bool, | |
| 34 | + | /// Signed actor assertion the CLI forwards (as `X-MNW-Actor`) on subsequent | |
| 35 | + | /// internal calls so the server derives identity from an SSH-authenticated | |
| 36 | + | /// token, not a caller-supplied `user_id`. | |
| 37 | + | actor_token: String, | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | /// GET /api/internal/ssh-key-lookup?fingerprint={sha256} | |
| @@ -45,6 +50,12 @@ pub(super) async fn ssh_key_lookup( | |||
| 45 | 50 | .await? | |
| 46 | 51 | .ok_or(AppError::NotFound)?; | |
| 47 | 52 | ||
| 53 | + | // Mint an actor assertion for the resolved user; the CLI forwards it on the | |
| 54 | + | // session's internal calls. TTL comfortably exceeds any SSH session. | |
| 55 | + | let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS; | |
| 56 | + | let actor_token = | |
| 57 | + | crate::crypto::mint_internal_actor_token(user.user_id, expiry, &state.config.signing_secret); | |
| 58 | + | ||
| 48 | 59 | Ok(Json(SshKeyLookupResponse { | |
| 49 | 60 | user_id: user.user_id, | |
| 50 | 61 | username: user.username, | |
| @@ -52,6 +63,7 @@ pub(super) async fn ssh_key_lookup( | |||
| 52 | 63 | creator_tier: user.creator_tier, | |
| 53 | 64 | can_create_projects: user.can_create_projects, | |
| 54 | 65 | suspended: user.suspended, | |
| 66 | + | actor_token, | |
| 55 | 67 | })) | |
| 56 | 68 | } | |
| 57 | 69 | ||
| @@ -59,7 +71,6 @@ pub(super) async fn ssh_key_lookup( | |||
| 59 | 71 | ||
| 60 | 72 | #[derive(Deserialize)] | |
| 61 | 73 | pub(super) struct UserIdQuery { | |
| 62 | - | user_id: UserId, | |
| 63 | 74 | } | |
| 64 | 75 | ||
| 65 | 76 | #[derive(Serialize)] | |
| @@ -76,10 +87,11 @@ struct SshKeyResponse { | |||
| 76 | 87 | #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")] | |
| 77 | 88 | pub(super) async fn list_ssh_keys( | |
| 78 | 89 | State(state): State<AppState>, | |
| 90 | + | actor: InternalActor, | |
| 79 | 91 | _auth: ServiceAuth, | |
| 80 | - | Query(query): Query<UserIdQuery>, | |
| 92 | + | Query(_query): Query<UserIdQuery>, | |
| 81 | 93 | ) -> Result<impl IntoResponse> { | |
| 82 | - | let keys = db::ssh_keys::list_keys_by_user(&state.db, query.user_id).await?; | |
| 94 | + | let keys = db::ssh_keys::list_keys_by_user(&state.db, actor.user_id()).await?; | |
| 83 | 95 | let data: Vec<SshKeyResponse> = keys | |
| 84 | 96 | .into_iter() | |
| 85 | 97 | .map(|k| SshKeyResponse { | |
| @@ -97,7 +109,6 @@ pub(super) async fn list_ssh_keys( | |||
| 97 | 109 | ||
| 98 | 110 | #[derive(Deserialize)] | |
| 99 | 111 | pub(super) struct GitAuthorizeRequest { | |
| 100 | - | user_id: UserId, | |
| 101 | 112 | /// "git-upload-pack", "git-receive-pack", or "git-upload-archive" | |
| 102 | 113 | operation: String, | |
| 103 | 114 | owner: String, | |
| @@ -116,6 +127,7 @@ struct GitAuthorizeResponse { | |||
| 116 | 127 | #[tracing::instrument(skip_all, name = "internal::git_authorize")] | |
| 117 | 128 | pub(super) async fn git_authorize( | |
| 118 | 129 | State(state): State<AppState>, | |
| 130 | + | actor: InternalActor, | |
| 119 | 131 | _auth: ServiceAuth, | |
| 120 | 132 | Json(req): Json<GitAuthorizeRequest>, | |
| 121 | 133 | ) -> Result<impl IntoResponse> { | |
| @@ -145,7 +157,7 @@ pub(super) async fn git_authorize( | |||
| 145 | 157 | // Auto-create on push if the authenticated user owns the namespace. | |
| 146 | 158 | // Only register in the DB here — mnw-cli creates the bare repo on | |
| 147 | 159 | // disk as the git user (avoids ownership/privilege issues). | |
| 148 | - | if req.operation != "git-receive-pack" || req.user_id != owner_user.id { | |
| 160 | + | if req.operation != "git-receive-pack" || actor.user_id() != owner_user.id { | |
| 149 | 161 | return Err(AppError::NotFound); | |
| 150 | 162 | } | |
| 151 | 163 | ||
| @@ -168,12 +180,12 @@ pub(super) async fn git_authorize( | |||
| 168 | 180 | // Permission check | |
| 169 | 181 | match req.operation.as_str() { | |
| 170 | 182 | "git-receive-pack" => { | |
| 171 | - | if req.user_id != owner_user.id { | |
| 183 | + | if actor.user_id() != owner_user.id { | |
| 172 | 184 | return Err(AppError::Forbidden); | |
| 173 | 185 | } | |
| 174 | 186 | } | |
| 175 | 187 | "git-upload-pack" | "git-upload-archive" => { | |
| 176 | - | if repo.visibility == Visibility::Private && req.user_id != owner_user.id { | |
| 188 | + | if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id { | |
| 177 | 189 | return Err(AppError::NotFound); | |
| 178 | 190 | } | |
| 179 | 191 | } |
| @@ -6,10 +6,11 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::auth::InternalActor; | |
| 9 | 10 | ||
| 10 | 11 | use crate::{ | |
| 11 | 12 | auth::ServiceAuth, | |
| 12 | - | db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId}, | |
| 13 | + | db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId}, | |
| 13 | 14 | error::{AppError, Result}, | |
| 14 | 15 | validation, | |
| 15 | 16 | AppState, | |
| @@ -68,14 +69,12 @@ impl ItemDetailResponse { | |||
| 68 | 69 | ||
| 69 | 70 | #[derive(Deserialize)] | |
| 70 | 71 | pub(super) struct ItemUserQuery { | |
| 71 | - | user_id: UserId, | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // ── Create item (for CLI upload pipeline) ── | |
| 75 | 75 | ||
| 76 | 76 | #[derive(Deserialize)] | |
| 77 | 77 | pub(super) struct CreateItemRequest { | |
| 78 | - | user_id: UserId, | |
| 79 | 78 | project_id: ProjectId, | |
| 80 | 79 | title: String, | |
| 81 | 80 | item_type: String, | |
| @@ -99,6 +98,7 @@ struct CreateItemResponse { | |||
| 99 | 98 | #[tracing::instrument(skip_all, name = "internal::create_item")] | |
| 100 | 99 | pub(super) async fn create_item( | |
| 101 | 100 | State(state): State<AppState>, | |
| 101 | + | actor: InternalActor, | |
| 102 | 102 | _auth: ServiceAuth, | |
| 103 | 103 | Json(req): Json<CreateItemRequest>, | |
| 104 | 104 | ) -> Result<impl IntoResponse> { | |
| @@ -115,7 +115,7 @@ pub(super) async fn create_item( | |||
| 115 | 115 | let project = db::projects::get_project_by_id(&state.db, req.project_id) | |
| 116 | 116 | .await? | |
| 117 | 117 | .ok_or(AppError::NotFound)?; | |
| 118 | - | if project.user_id != req.user_id { | |
| 118 | + | if project.user_id != actor.user_id() { | |
| 119 | 119 | return Err(AppError::Forbidden); | |
| 120 | 120 | } | |
| 121 | 121 | ||
| @@ -133,7 +133,7 @@ pub(super) async fn create_item( | |||
| 133 | 133 | .await?; | |
| 134 | 134 | ||
| 135 | 135 | tracing::info!( | |
| 136 | - | user = %req.user_id, | |
| 136 | + | user = %actor.user_id(), | |
| 137 | 137 | item = %item.id, | |
| 138 | 138 | "item created via CLI" | |
| 139 | 139 | ); | |
| @@ -152,9 +152,10 @@ pub(super) async fn create_item( | |||
| 152 | 152 | #[tracing::instrument(skip_all, name = "internal::get_item")] | |
| 153 | 153 | pub(super) async fn get_item( | |
| 154 | 154 | State(state): State<AppState>, | |
| 155 | + | actor: InternalActor, | |
| 155 | 156 | _auth: ServiceAuth, | |
| 156 | 157 | Path(item_id): Path<ItemId>, | |
| 157 | - | Query(query): Query<ItemUserQuery>, | |
| 158 | + | Query(_query): Query<ItemUserQuery>, | |
| 158 | 159 | ) -> Result<impl IntoResponse> { | |
| 159 | 160 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 160 | 161 | .await? | |
| @@ -164,7 +165,7 @@ pub(super) async fn get_item( | |||
| 164 | 165 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 165 | 166 | .await? | |
| 166 | 167 | .ok_or(AppError::NotFound)?; | |
| 167 | - | if project.user_id != query.user_id { | |
| 168 | + | if project.user_id != actor.user_id() { | |
| 168 | 169 | return Err(AppError::Forbidden); | |
| 169 | 170 | } | |
| 170 | 171 | ||
| @@ -175,7 +176,6 @@ pub(super) async fn get_item( | |||
| 175 | 176 | ||
| 176 | 177 | #[derive(Deserialize)] | |
| 177 | 178 | pub(super) struct UpdateItemRequest { | |
| 178 | - | user_id: UserId, | |
| 179 | 179 | #[serde(default)] | |
| 180 | 180 | title: Option<String>, | |
| 181 | 181 | #[serde(default)] | |
| @@ -200,6 +200,7 @@ pub(super) struct UpdateItemRequest { | |||
| 200 | 200 | #[tracing::instrument(skip_all, name = "internal::update_item")] | |
| 201 | 201 | pub(super) async fn update_item( | |
| 202 | 202 | State(state): State<AppState>, | |
| 203 | + | actor: InternalActor, | |
| 203 | 204 | _auth: ServiceAuth, | |
| 204 | 205 | Path(item_id): Path<ItemId>, | |
| 205 | 206 | Json(req): Json<UpdateItemRequest>, | |
| @@ -211,7 +212,7 @@ pub(super) async fn update_item( | |||
| 211 | 212 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 212 | 213 | .await? | |
| 213 | 214 | .ok_or(AppError::NotFound)?; | |
| 214 | - | if project.user_id != req.user_id { | |
| 215 | + | if project.user_id != actor.user_id() { | |
| 215 | 216 | return Err(AppError::Forbidden); | |
| 216 | 217 | } | |
| 217 | 218 | ||
| @@ -234,7 +235,7 @@ pub(super) async fn update_item( | |||
| 234 | 235 | let updated = db::items::update_item( | |
| 235 | 236 | &state.db, | |
| 236 | 237 | item_id, | |
| 237 | - | req.user_id, | |
| 238 | + | actor.user_id(), | |
| 238 | 239 | req.title.as_deref(), | |
| 239 | 240 | req.description.as_deref(), | |
| 240 | 241 | req.price_cents.map(PriceCents::new).transpose()?, | |
| @@ -249,7 +250,7 @@ pub(super) async fn update_item( | |||
| 249 | 250 | ) | |
| 250 | 251 | .await?; | |
| 251 | 252 | ||
| 252 | - | tracing::info!(user = %req.user_id, item = %item_id, "item updated via CLI"); | |
| 253 | + | tracing::info!(user = %actor.user_id(), item = %item_id, "item updated via CLI"); | |
| 253 | 254 | ||
| 254 | 255 | Ok(Json(ItemDetailResponse::from_db(&updated))) | |
| 255 | 256 | } | |
| @@ -262,9 +263,10 @@ pub(super) async fn update_item( | |||
| 262 | 263 | #[tracing::instrument(skip_all, name = "internal::delete_item")] | |
| 263 | 264 | pub(super) async fn delete_item( | |
| 264 | 265 | State(state): State<AppState>, | |
| 266 | + | actor: InternalActor, | |
| 265 | 267 | _auth: ServiceAuth, | |
| 266 | 268 | Path(item_id): Path<ItemId>, | |
| 267 | - | Query(query): Query<ItemUserQuery>, | |
| 269 | + | Query(_query): Query<ItemUserQuery>, | |
| 268 | 270 | ) -> Result<impl IntoResponse> { | |
| 269 | 271 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 270 | 272 | .await? | |
| @@ -273,7 +275,7 @@ pub(super) async fn delete_item( | |||
| 273 | 275 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 274 | 276 | .await? | |
| 275 | 277 | .ok_or(AppError::NotFound)?; | |
| 276 | - | if project.user_id != query.user_id { | |
| 278 | + | if project.user_id != actor.user_id() { | |
| 277 | 279 | return Err(AppError::Forbidden); | |
| 278 | 280 | } | |
| 279 | 281 | ||
| @@ -285,12 +287,12 @@ pub(super) async fn delete_item( | |||
| 285 | 287 | + file_sizes.video_file_size_bytes.unwrap_or(0) | |
| 286 | 288 | + version_size; | |
| 287 | 289 | if total_bytes > 0 { | |
| 288 | - | db::creator_tiers::decrement_storage_used(&state.db, query.user_id, total_bytes).await?; | |
| 290 | + | db::creator_tiers::decrement_storage_used(&state.db, actor.user_id(), total_bytes).await?; | |
| 289 | 291 | } | |
| 290 | 292 | ||
| 291 | - | db::items::delete_item(&state.db, item_id, query.user_id).await?; | |
| 293 | + | db::items::delete_item(&state.db, item_id, actor.user_id()).await?; | |
| 292 | 294 | ||
| 293 | - | tracing::info!(user = %query.user_id, item = %item_id, "item deleted via CLI"); | |
| 295 | + | tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI"); | |
| 294 | 296 | ||
| 295 | 297 | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 296 | 298 | } | |
| @@ -299,7 +301,6 @@ pub(super) async fn delete_item( | |||
| 299 | 301 | ||
| 300 | 302 | #[derive(Deserialize)] | |
| 301 | 303 | pub(super) struct PublishRequest { | |
| 302 | - | user_id: UserId, | |
| 303 | 304 | } | |
| 304 | 305 | ||
| 305 | 306 | /// POST /api/internal/creator/items/{id}/publish | |
| @@ -308,9 +309,10 @@ pub(super) struct PublishRequest { | |||
| 308 | 309 | #[tracing::instrument(skip_all, name = "internal::publish_item")] | |
| 309 | 310 | pub(super) async fn publish_item( | |
| 310 | 311 | State(state): State<AppState>, | |
| 312 | + | actor: InternalActor, | |
| 311 | 313 | _auth: ServiceAuth, | |
| 312 | 314 | Path(item_id): Path<ItemId>, | |
| 313 | - | Json(req): Json<PublishRequest>, | |
| 315 | + | Json(_req): Json<PublishRequest>, | |
| 314 | 316 | ) -> Result<impl IntoResponse> { | |
| 315 | 317 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 316 | 318 | .await? | |
| @@ -319,14 +321,14 @@ pub(super) async fn publish_item( | |||
| 319 | 321 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 320 | 322 | .await? | |
| 321 | 323 | .ok_or(AppError::NotFound)?; | |
| 322 | - | if project.user_id != req.user_id { | |
| 324 | + | if project.user_id != actor.user_id() { | |
| 323 | 325 | return Err(AppError::Forbidden); | |
| 324 | 326 | } | |
| 325 | 327 | ||
| 326 | 328 | let updated = db::items::update_item( | |
| 327 | 329 | &state.db, | |
| 328 | 330 | item_id, | |
| 329 | - | req.user_id, | |
| 331 | + | actor.user_id(), | |
| 330 | 332 | None, None, None, None, | |
| 331 | 333 | Some(true), // is_public | |
| 332 | 334 | None, None, None, None, | |
| @@ -337,7 +339,7 @@ pub(super) async fn publish_item( | |||
| 337 | 339 | if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await { | |
| 338 | 340 | tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish"); | |
| 339 | 341 | } | |
| 340 | - | tracing::info!(user = %req.user_id, item = %item_id, "item published via CLI"); | |
| 342 | + | tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI"); | |
| 341 | 343 | ||
| 342 | 344 | Ok(Json(ItemDetailResponse::from_db(&updated))) | |
| 343 | 345 | } | |
| @@ -348,9 +350,10 @@ pub(super) async fn publish_item( | |||
| 348 | 350 | #[tracing::instrument(skip_all, name = "internal::unpublish_item")] | |
| 349 | 351 | pub(super) async fn unpublish_item( | |
| 350 | 352 | State(state): State<AppState>, | |
| 353 | + | actor: InternalActor, | |
| 351 | 354 | _auth: ServiceAuth, | |
| 352 | 355 | Path(item_id): Path<ItemId>, | |
| 353 | - | Json(req): Json<PublishRequest>, | |
| 356 | + | Json(_req): Json<PublishRequest>, | |
| 354 | 357 | ) -> Result<impl IntoResponse> { | |
| 355 | 358 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 356 | 359 | .await? | |
| @@ -359,14 +362,14 @@ pub(super) async fn unpublish_item( | |||
| 359 | 362 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 360 | 363 | .await? | |
| 361 | 364 | .ok_or(AppError::NotFound)?; | |
| 362 | - | if project.user_id != req.user_id { | |
| 365 | + | if project.user_id != actor.user_id() { | |
| 363 | 366 | return Err(AppError::Forbidden); | |
| 364 | 367 | } | |
| 365 | 368 | ||
| 366 | 369 | let updated = db::items::update_item( | |
| 367 | 370 | &state.db, | |
| 368 | 371 | item_id, | |
| 369 | - | req.user_id, | |
| 372 | + | actor.user_id(), | |
| 370 | 373 | None, None, None, None, | |
| 371 | 374 | Some(false), // is_public | |
| 372 | 375 | None, None, None, None, | |
| @@ -377,7 +380,7 @@ pub(super) async fn unpublish_item( | |||
| 377 | 380 | if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await { | |
| 378 | 381 | tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish"); | |
| 379 | 382 | } | |
| 380 | - | tracing::info!(user = %req.user_id, item = %item_id, "item unpublished via CLI"); | |
| 383 | + | tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI"); | |
| 381 | 384 | ||
| 382 | 385 | Ok(Json(ItemDetailResponse::from_db(&updated))) | |
| 383 | 386 | } | |
| @@ -402,9 +405,10 @@ struct VersionResponse { | |||
| 402 | 405 | #[tracing::instrument(skip_all, name = "internal::item_versions")] | |
| 403 | 406 | pub(super) async fn item_versions( | |
| 404 | 407 | State(state): State<AppState>, | |
| 408 | + | actor: InternalActor, | |
| 405 | 409 | _auth: ServiceAuth, | |
| 406 | 410 | Path(item_id): Path<ItemId>, | |
| 407 | - | Query(query): Query<ItemUserQuery>, | |
| 411 | + | Query(_query): Query<ItemUserQuery>, | |
| 408 | 412 | ) -> Result<impl IntoResponse> { | |
| 409 | 413 | let item = db::items::get_item_by_id(&state.db, item_id) | |
| 410 | 414 | .await? | |
| @@ -413,7 +417,7 @@ pub(super) async fn item_versions( | |||
| 413 | 417 | let project = db::projects::get_project_by_id(&state.db, item.project_id) | |
| 414 | 418 | .await? | |
| 415 | 419 | .ok_or(AppError::NotFound)?; | |
| 416 | - | if project.user_id != query.user_id { | |
| 420 | + | if project.user_id != actor.user_id() { | |
| 417 | 421 | return Err(AppError::Forbidden); | |
| 418 | 422 | } | |
| 419 | 423 |
| @@ -6,11 +6,12 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | + | use crate::auth::InternalActor; | |
| 9 | 10 | use std::str::FromStr; | |
| 10 | 11 | ||
| 11 | 12 | use crate::{ | |
| 12 | 13 | auth::ServiceAuth, | |
| 13 | - | db::{self, ItemId, UserId}, | |
| 14 | + | db::{self, ItemId}, | |
| 14 | 15 | error::{AppError, Result}, | |
| 15 | 16 | storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE}, | |
| 16 | 17 | AppState, | |
| @@ -20,7 +21,6 @@ use crate::{ | |||
| 20 | 21 | ||
| 21 | 22 | #[derive(Deserialize)] | |
| 22 | 23 | pub(super) struct InternalPresignRequest { | |
| 23 | - | user_id: UserId, | |
| 24 | 24 | item_id: ItemId, | |
| 25 | 25 | file_type: String, | |
| 26 | 26 | file_name: String, | |
| @@ -42,6 +42,7 @@ struct InternalPresignResponse { | |||
| 42 | 42 | #[tracing::instrument(skip_all, name = "internal::presign_upload")] | |
| 43 | 43 | pub(super) async fn presign_upload( | |
| 44 | 44 | State(state): State<AppState>, | |
| 45 | + | actor: InternalActor, | |
| 45 | 46 | _auth: ServiceAuth, | |
| 46 | 47 | Json(req): Json<InternalPresignRequest>, | |
| 47 | 48 | ) -> Result<impl IntoResponse> { | |
| @@ -57,17 +58,17 @@ pub(super) async fn presign_upload( | |||
| 57 | 58 | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 58 | 59 | .await? | |
| 59 | 60 | .ok_or(AppError::NotFound)?; | |
| 60 | - | if owner != req.user_id { | |
| 61 | + | if owner != actor.user_id() { | |
| 61 | 62 | return Err(AppError::Forbidden); | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 65 | // Early quota check | |
| 65 | - | db::creator_tiers::check_presign_allowed(&state.db, req.user_id, file_type).await?; | |
| 66 | + | db::creator_tiers::check_presign_allowed(&state.db, actor.user_id(), file_type).await?; | |
| 66 | 67 | ||
| 67 | - | let s3_key = S3Client::generate_key(req.user_id, req.item_id, file_type, &req.file_name); | |
| 68 | + | let s3_key = S3Client::generate_key(actor.user_id(), req.item_id, file_type, &req.file_name); | |
| 68 | 69 | ||
| 69 | 70 | // Track the pending upload so the reaper can clean it up if never confirmed | |
| 70 | - | db::pending_uploads::record_pending_upload(&state.db, req.user_id, &s3_key, "main").await?; | |
| 71 | + | db::pending_uploads::record_pending_upload(&state.db, actor.user_id(), &s3_key, "main").await?; | |
| 71 | 72 | ||
| 72 | 73 | let expires_in = 3600; | |
| 73 | 74 | let upload_url = s3 | |
| @@ -92,7 +93,6 @@ pub(super) async fn presign_upload( | |||
| 92 | 93 | ||
| 93 | 94 | #[derive(Deserialize)] | |
| 94 | 95 | pub(super) struct InternalConfirmRequest { | |
| 95 | - | user_id: UserId, | |
| 96 | 96 | item_id: ItemId, | |
| 97 | 97 | file_type: String, | |
| 98 | 98 | s3_key: String, | |
| @@ -109,6 +109,7 @@ struct InternalConfirmResponse { | |||
| 109 | 109 | #[tracing::instrument(skip_all, name = "internal::confirm_upload")] | |
| 110 | 110 | pub(super) async fn confirm_upload( | |
| 111 | 111 | State(state): State<AppState>, | |
| 112 | + | actor: InternalActor, | |
| 112 | 113 | _auth: ServiceAuth, | |
| 113 | 114 | Json(req): Json<InternalConfirmRequest>, | |
| 114 | 115 | ) -> Result<impl IntoResponse> { | |
| @@ -121,12 +122,12 @@ pub(super) async fn confirm_upload( | |||
| 121 | 122 | let owner = db::items::get_item_owner(&state.db, req.item_id) | |
| 122 | 123 | .await? | |
| 123 | 124 | .ok_or(AppError::NotFound)?; | |
| 124 | - | if owner != req.user_id { | |
| 125 | + | if owner != actor.user_id() { | |
| 125 | 126 | return Err(AppError::Forbidden); | |
| 126 | 127 | } | |
| 127 | 128 | ||
| 128 | 129 | // Validate S3 key belongs to this user + item (prevent cross-user file reference) | |
| 129 | - | let expected_prefix = format!("{}/{}/", req.user_id, req.item_id); | |
| 130 | + | let expected_prefix = format!("{}/{}/", actor.user_id(), req.item_id); | |
| 130 | 131 | if !req.s3_key.starts_with(&expected_prefix) { | |
| 131 | 132 | return Err(AppError::BadRequest("Invalid upload key".to_string())); | |
| 132 | 133 | } | |
| @@ -161,7 +162,7 @@ pub(super) async fn confirm_upload( | |||
| 161 | 162 | }; | |
| 162 | 163 | if already_committed { | |
| 163 | 164 | tracing::info!( | |
| 164 | - | user = %req.user_id, item = %req.item_id, s3_key = %req.s3_key, | |
| 165 | + | user = %actor.user_id(), item = %req.item_id, s3_key = %req.s3_key, | |
| 165 | 166 | "CLI upload confirm replay — already committed, skipping duplicate side effects" | |
| 166 | 167 | ); | |
| 167 | 168 | return Ok(Json(InternalConfirmResponse { success: true })); | |
| @@ -182,7 +183,7 @@ pub(super) async fn confirm_upload( | |||
| 182 | 183 | // Enforce tier-based limits | |
| 183 | 184 | let max_storage = match db::creator_tiers::check_upload_allowed( | |
| 184 | 185 | &state.db, | |
| 185 | - | req.user_id, | |
| 186 | + | actor.user_id(), | |
| 186 | 187 | file_type, | |
| 187 | 188 | file_size_bytes, | |
| 188 | 189 | ) | |
| @@ -206,19 +207,19 @@ pub(super) async fn confirm_upload( | |||
| 206 | 207 | ||
| 207 | 208 | // Increment storage BEFORE writing the DB record (if quota exceeded, the | |
| 208 | 209 | // S3 object is cleaned up and no DB record is created). | |
| 209 | - | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, req.user_id, file_size_bytes, max_storage).await { | |
| 210 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, actor.user_id(), file_size_bytes, max_storage).await { | |
| 210 | 211 | crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await; | |
| 211 | 212 | return Err(e); | |
| 212 | 213 | } | |
| 213 | 214 | ||
| 214 | - | db::pending_uploads::remove_pending_upload(&state.db, req.user_id, &req.s3_key, "main").await?; | |
| 215 | + | db::pending_uploads::remove_pending_upload(&state.db, actor.user_id(), &req.s3_key, "main").await?; | |
| 215 | 216 | ||
| 216 | 217 | // Update the database with S3 key and file size. | |
| 217 | 218 | let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string()); | |
| 218 | 219 | let commit_target = match file_type { | |
| 219 | 220 | FileType::Audio => { | |
| 220 | - | db::items::update_item_audio_s3_key(&state.db, req.item_id, req.user_id, &req.s3_key).await?; | |
| 221 | - | db::items::update_item_audio_file_size(&state.db, req.item_id, req.user_id, file_size_bytes) | |
| 221 | + | db::items::update_item_audio_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?; | |
| 222 | + | db::items::update_item_audio_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes) | |
| 222 | 223 | .await?; | |
| 223 | 224 | crate::routes::storage::CommitTarget::Item(req.item_id) | |
| 224 | 225 | } | |
| @@ -237,8 +238,8 @@ pub(super) async fn confirm_upload( | |||
| 237 | 238 | crate::routes::storage::CommitTarget::Version(version.id) | |
| 238 | 239 | } | |
| 239 | 240 | FileType::Video => { | |
| 240 | - | db::items::update_item_video_s3_key(&state.db, req.item_id, req.user_id, &req.s3_key).await?; | |
| 241 | - | db::items::update_item_video_file_size(&state.db, req.item_id, req.user_id, file_size_bytes) | |
| 241 | + | db::items::update_item_video_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?; | |
| 242 | + | db::items::update_item_video_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes) | |
| 242 | 243 | .await?; | |
| 243 | 244 | crate::routes::storage::CommitTarget::Item(req.item_id) | |
| 244 | 245 | } | |
| @@ -252,7 +253,7 @@ pub(super) async fn confirm_upload( | |||
| 252 | 253 | commit_target, | |
| 253 | 254 | &req.s3_key, | |
| 254 | 255 | file_type, | |
| 255 | - | req.user_id, | |
| 256 | + | actor.user_id(), | |
| 256 | 257 | file_size_bytes, | |
| 257 | 258 | ).await?; | |
| 258 | 259 | ||
| @@ -264,7 +265,7 @@ pub(super) async fn confirm_upload( | |||
| 264 | 265 | } | |
| 265 | 266 | ||
| 266 | 267 | tracing::info!( | |
| 267 | - | user = %req.user_id, | |
| 268 | + | user = %actor.user_id(), | |
| 268 | 269 | item = %req.item_id, | |
| 269 | 270 | file_type = ?file_type, | |
| 270 | 271 | s3_key = %req.s3_key, | |
| @@ -279,7 +280,6 @@ pub(super) async fn confirm_upload( | |||
| 279 | 280 | ||
| 280 | 281 | #[derive(Deserialize)] | |
| 281 | 282 | pub(super) struct UserIdQuery { | |
| 282 | - | user_id: UserId, | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | 285 | #[derive(Serialize)] | |
| @@ -295,13 +295,14 @@ struct StorageInfoResponse { | |||
| 295 | 295 | #[tracing::instrument(skip_all, name = "internal::creator_storage")] | |
| 296 | 296 | pub(super) async fn creator_storage( | |
| 297 | 297 | State(state): State<AppState>, | |
| 298 | + | actor: InternalActor, | |
| 298 | 299 | _auth: ServiceAuth, | |
| 299 | - | Query(query): Query<UserIdQuery>, | |
| 300 | + | Query(_query): Query<UserIdQuery>, | |
| 300 | 301 | ) -> Result<impl IntoResponse> { | |
| 301 | - | let used = db::creator_tiers::get_storage_used(&state.db, query.user_id).await?; | |
| 302 | + | let used = db::creator_tiers::get_storage_used(&state.db, actor.user_id()).await?; | |
| 302 | 303 | ||
| 303 | 304 | // Resolve effective tier | |
| 304 | - | let tier = db::creator_tiers::get_active_creator_tier(&state.db, query.user_id).await?; | |
| 305 | + | let tier = db::creator_tiers::get_active_creator_tier(&state.db, actor.user_id()).await?; | |
| 305 | 306 | ||
| 306 | 307 | let (max_storage, allows_uploads) = match tier { | |
| 307 | 308 | Some(t) => (t.max_storage_bytes(), t.allows_file_uploads()), |
| @@ -21,6 +21,7 @@ pub struct TestClient { | |||
| 21 | 21 | csrf_token: Option<String>, | |
| 22 | 22 | forwarded_ip: String, | |
| 23 | 23 | bearer_token: Option<String>, | |
| 24 | + | actor_token: Option<String>, | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | impl TestClient { | |
| @@ -34,6 +35,7 @@ impl TestClient { | |||
| 34 | 35 | csrf_token: None, | |
| 35 | 36 | forwarded_ip: format!("10.1.{}.{}", octet3, octet4), | |
| 36 | 37 | bearer_token: None, | |
| 38 | + | actor_token: None, | |
| 37 | 39 | } | |
| 38 | 40 | } | |
| 39 | 41 | ||
| @@ -55,6 +57,13 @@ impl TestClient { | |||
| 55 | 57 | self.bearer_token = None; | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 60 | + | /// Set the `X-MNW-Actor` assertion sent on internal-API requests (mirrors | |
| 61 | + | /// what the CLI forwards after ssh-key-lookup). | |
| 62 | + | #[allow(dead_code)] | |
| 63 | + | pub fn set_actor_token(&mut self, token: &str) { | |
| 64 | + | self.actor_token = Some(token.to_string()); | |
| 65 | + | } | |
| 66 | + | ||
| 58 | 67 | /// Drop all stored cookies — simulates a fresh client with no session, e.g. | |
| 59 | 68 | /// a CLI `git` request that authenticates via a token rather than a browser | |
| 60 | 69 | /// cookie. | |
| @@ -251,6 +260,9 @@ impl TestClient { | |||
| 251 | 260 | if let Some(ref token) = self.bearer_token { | |
| 252 | 261 | builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token)); | |
| 253 | 262 | } | |
| 263 | + | if let Some(ref actor) = self.actor_token { | |
| 264 | + | builder = builder.header("X-MNW-Actor", actor); | |
| 265 | + | } | |
| 254 | 266 | ||
| 255 | 267 | // Set content type | |
| 256 | 268 | if let Some(ct) = content_type { | |
| @@ -410,6 +422,9 @@ impl TestClient { | |||
| 410 | 422 | if let Some(ref token) = self.bearer_token { | |
| 411 | 423 | builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token)); | |
| 412 | 424 | } | |
| 425 | + | if let Some(ref actor) = self.actor_token { | |
| 426 | + | builder = builder.header("X-MNW-Actor", actor); | |
| 427 | + | } | |
| 413 | 428 | ||
| 414 | 429 | if !self.cookies.is_empty() { | |
| 415 | 430 | let cookie_header: String = self |
| @@ -121,6 +121,12 @@ async fn internal_confirm_upload_replay_is_idempotent() { | |||
| 121 | 121 | "file_type": "audio", "s3_key": s3_key, | |
| 122 | 122 | }); | |
| 123 | 123 | h.client.set_bearer_token("test-cli-token"); | |
| 124 | + | let actor = makenotwork::crypto::mint_internal_actor_token( | |
| 125 | + | user_id, | |
| 126 | + | chrono::Utc::now().timestamp() + 3600, | |
| 127 | + | "test-signing-secret-for-integration-tests", | |
| 128 | + | ); | |
| 129 | + | h.client.set_actor_token(&actor); | |
| 124 | 130 | let r1 = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await; | |
| 125 | 131 | assert!(r1.status.is_success(), "first internal confirm failed: {}", r1.text); | |
| 126 | 132 | let r2 = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await; | |
| @@ -145,6 +151,34 @@ async fn internal_confirm_upload_replay_is_idempotent() { | |||
| 145 | 151 | assert_eq!(storage_used, expected_size, "replay must not double-charge storage"); | |
| 146 | 152 | } | |
| 147 | 153 | ||
| 154 | + | /// A valid ServiceAuth bearer alone is not enough to act on the internal API: | |
| 155 | + | /// without a valid `X-MNW-Actor` assertion the request is rejected. Proves a | |
| 156 | + | /// leaked service token cannot name an arbitrary user (audit 2026-07-01). | |
| 157 | + | #[tokio::test] | |
| 158 | + | async fn internal_confirm_without_actor_token_rejected() { | |
| 159 | + | let mem = std::sync::Arc::new(crate::harness::storage::InMemoryStorage::new()); | |
| 160 | + | let mut h = TestHarness::build(crate::harness::BuildOptions { | |
| 161 | + | storage: Some(mem), | |
| 162 | + | cli_service_token: Some("test-cli-token".to_string()), | |
| 163 | + | ..Default::default() | |
| 164 | + | }) | |
| 165 | + | .await; | |
| 166 | + | ||
| 167 | + | let setup = h.create_creator_with_item("noactor", "audio", 0).await; | |
| 168 | + | h.trust_user(setup.user_id).await; | |
| 169 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 170 | + | ||
| 171 | + | let confirm = json!({ | |
| 172 | + | "user_id": setup.user_id, "item_id": setup.item_id, | |
| 173 | + | "file_type": "audio", "s3_key": "noactor/whatever.mp3", | |
| 174 | + | }); | |
| 175 | + | // Bearer set, but no actor assertion. | |
| 176 | + | h.client.set_bearer_token("test-cli-token"); | |
| 177 | + | let resp = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await; | |
| 178 | + | assert_eq!(resp.status.as_u16(), 401, "missing actor assertion must be rejected: {}", resp.text); | |
| 179 | + | h.client.clear_bearer_token(); | |
| 180 | + | } | |
| 181 | + | ||
| 148 | 182 | #[tokio::test] | |
| 149 | 183 | async fn confirm_item_cover_via_dedicated_route_writes_key_and_url() { | |
| 150 | 184 | // Covers go through /api/items/image/{presign,confirm}, which writes |