max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
13 files changed,
+439 insertions,
-147 deletions
| @@ -11,6 +11,11 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 | } |
| @@ -425,6 +425,54 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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] |
| @@ -107,6 +107,10 @@ | |||
| 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 | } |
| @@ -21,6 +21,7 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 | } |