|
7 | 7 | use std::net::IpAddr; |
8 | 8 |
|
9 | 9 | use chrono::{DateTime, Utc}; |
10 | | -use mas_data_model::Device; |
| 10 | +use mas_data_model::{ |
| 11 | + Device, |
| 12 | + personal::{ |
| 13 | + PersonalAccessToken as DataModelPersonalAccessToken, |
| 14 | + session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner}, |
| 15 | + }, |
| 16 | +}; |
11 | 17 | use schemars::JsonSchema; |
12 | 18 | use serde::Serialize; |
| 19 | +use thiserror::Error; |
13 | 20 | use ulid::Ulid; |
14 | 21 | use url::Url; |
15 | 22 |
|
@@ -771,3 +778,179 @@ impl UpstreamOAuthProvider { |
771 | 778 | ] |
772 | 779 | } |
773 | 780 | } |
| 781 | + |
| 782 | +/// An error that shouldn't happen in practice, but suggests database |
| 783 | +/// inconsistency. |
| 784 | +#[derive(Debug, Error)] |
| 785 | +#[error( |
| 786 | + "personal session {session_id} in inconsistent state: not revoked but no valid access token" |
| 787 | +)] |
| 788 | +pub struct InconsistentPersonalSession { |
| 789 | + pub session_id: Ulid, |
| 790 | +} |
| 791 | + |
| 792 | +// Note: we don't expose a separate concept of personal access tokens to the |
| 793 | +// admin API; we merge the relevant attributes into the personal session. |
| 794 | +/// A personal session (session using personal access tokens) |
| 795 | +#[derive(Serialize, JsonSchema)] |
| 796 | +pub struct PersonalSession { |
| 797 | + #[serde(skip)] |
| 798 | + id: Ulid, |
| 799 | + |
| 800 | + /// When the session was created |
| 801 | + created_at: DateTime<Utc>, |
| 802 | + |
| 803 | + /// When the session was revoked, if applicable |
| 804 | + revoked_at: Option<DateTime<Utc>>, |
| 805 | + |
| 806 | + /// The ID of the user who owns this session (if user-owned) |
| 807 | + #[schemars(with = "Option<super::schema::Ulid>")] |
| 808 | + owner_user_id: Option<Ulid>, |
| 809 | + |
| 810 | + /// The ID of the `OAuth2` client that owns this session (if client-owned) |
| 811 | + #[schemars(with = "Option<super::schema::Ulid>")] |
| 812 | + owner_client_id: Option<Ulid>, |
| 813 | + |
| 814 | + /// The ID of the user that the session acts on behalf of |
| 815 | + #[schemars(with = "super::schema::Ulid")] |
| 816 | + actor_user_id: Ulid, |
| 817 | + |
| 818 | + /// Human-readable name for the session |
| 819 | + human_name: String, |
| 820 | + |
| 821 | + /// `OAuth2` scopes for this session |
| 822 | + scope: String, |
| 823 | + |
| 824 | + /// When the session was last active |
| 825 | + last_active_at: Option<DateTime<Utc>>, |
| 826 | + |
| 827 | + /// IP address of last activity |
| 828 | + last_active_ip: Option<IpAddr>, |
| 829 | + |
| 830 | + /// When the current token for this session expires. |
| 831 | + /// The session will need to be regenerated, producing a new access token, |
| 832 | + /// after this time. |
| 833 | + /// None if the current token won't expire or if the session is revoked. |
| 834 | + expires_at: Option<DateTime<Utc>>, |
| 835 | + |
| 836 | + /// The actual access token (only returned on creation) |
| 837 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 838 | + access_token: Option<String>, |
| 839 | +} |
| 840 | + |
| 841 | +impl |
| 842 | + TryFrom<( |
| 843 | + DataModelPersonalSession, |
| 844 | + Option<DataModelPersonalAccessToken>, |
| 845 | + )> for PersonalSession |
| 846 | +{ |
| 847 | + type Error = InconsistentPersonalSession; |
| 848 | + |
| 849 | + fn try_from( |
| 850 | + (session, token): ( |
| 851 | + DataModelPersonalSession, |
| 852 | + Option<DataModelPersonalAccessToken>, |
| 853 | + ), |
| 854 | + ) -> Result<Self, InconsistentPersonalSession> { |
| 855 | + let expires_at = if let Some(token) = token { |
| 856 | + token.expires_at |
| 857 | + } else { |
| 858 | + if !session.is_revoked() { |
| 859 | + // No active token, but the session is not revoked. |
| 860 | + return Err(InconsistentPersonalSession { |
| 861 | + session_id: session.id, |
| 862 | + }); |
| 863 | + } |
| 864 | + None |
| 865 | + }; |
| 866 | + |
| 867 | + let (owner_user_id, owner_client_id) = match session.owner { |
| 868 | + PersonalSessionOwner::User(id) => (Some(id), None), |
| 869 | + PersonalSessionOwner::OAuth2Client(id) => (None, Some(id)), |
| 870 | + }; |
| 871 | + |
| 872 | + Ok(Self { |
| 873 | + id: session.id, |
| 874 | + created_at: session.created_at, |
| 875 | + revoked_at: session.revoked_at(), |
| 876 | + owner_user_id, |
| 877 | + owner_client_id, |
| 878 | + actor_user_id: session.actor_user_id, |
| 879 | + human_name: session.human_name, |
| 880 | + scope: session.scope.to_string(), |
| 881 | + last_active_at: session.last_active_at, |
| 882 | + last_active_ip: session.last_active_ip, |
| 883 | + expires_at, |
| 884 | + // If relevant, the caller will populate using `with_token` afterwards. |
| 885 | + access_token: None, |
| 886 | + }) |
| 887 | + } |
| 888 | +} |
| 889 | + |
| 890 | +impl Resource for PersonalSession { |
| 891 | + const KIND: &'static str = "personal-session"; |
| 892 | + const PATH: &'static str = "/api/admin/v1/personal-sessions"; |
| 893 | + |
| 894 | + fn id(&self) -> Ulid { |
| 895 | + self.id |
| 896 | + } |
| 897 | +} |
| 898 | + |
| 899 | +impl PersonalSession { |
| 900 | + /// Sample personal sessions for documentation/testing |
| 901 | + pub fn samples() -> [Self; 3] { |
| 902 | + [ |
| 903 | + Self { |
| 904 | + id: Ulid::from_string("01FSHN9AG0AJ6AC5HQ9X6H4RP4").unwrap(), |
| 905 | + created_at: DateTime::from_timestamp(1_642_338_000, 0).unwrap(), /* 2022-01-16T14: |
| 906 | + * 40:00Z */ |
| 907 | + revoked_at: None, |
| 908 | + owner_user_id: Some(Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap()), |
| 909 | + owner_client_id: None, |
| 910 | + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), |
| 911 | + human_name: "Alice's Development Token".to_owned(), |
| 912 | + scope: "openid urn:matrix:org.matrix.msc2967.client:api:*".to_owned(), |
| 913 | + last_active_at: Some(DateTime::from_timestamp(1_642_347_000, 0).unwrap()), /* 2022-01-16T17:10:00Z */ |
| 914 | + last_active_ip: Some("192.168.1.100".parse().unwrap()), |
| 915 | + expires_at: None, |
| 916 | + access_token: None, |
| 917 | + }, |
| 918 | + Self { |
| 919 | + id: Ulid::from_string("01FSHN9AG0BJ6AC5HQ9X6H4RP5").unwrap(), |
| 920 | + created_at: DateTime::from_timestamp(1_642_338_060, 0).unwrap(), /* 2022-01-16T14: |
| 921 | + * 41:00Z */ |
| 922 | + revoked_at: Some(DateTime::from_timestamp(1_642_350_000, 0).unwrap()), /* 2022-01-16T18:00:00Z */ |
| 923 | + owner_user_id: Some(Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap()), |
| 924 | + owner_client_id: None, |
| 925 | + actor_user_id: Ulid::from_string("01FSHN9AG0NZAA6S4AF7CTV32F").unwrap(), |
| 926 | + human_name: "Bob's Mobile App".to_owned(), |
| 927 | + scope: "openid".to_owned(), |
| 928 | + last_active_at: Some(DateTime::from_timestamp(1_642_349_000, 0).unwrap()), /* 2022-01-16T17:43:20Z */ |
| 929 | + last_active_ip: Some("10.0.0.50".parse().unwrap()), |
| 930 | + expires_at: None, |
| 931 | + access_token: None, |
| 932 | + }, |
| 933 | + Self { |
| 934 | + id: Ulid::from_string("01FSHN9AG0CJ6AC5HQ9X6H4RP6").unwrap(), |
| 935 | + created_at: DateTime::from_timestamp(1_642_338_120, 0).unwrap(), /* 2022-01-16T14: |
| 936 | + * 42:00Z */ |
| 937 | + revoked_at: None, |
| 938 | + owner_user_id: None, |
| 939 | + owner_client_id: Some(Ulid::from_string("01FSHN9AG0DJ6AC5HQ9X6H4RP7").unwrap()), |
| 940 | + actor_user_id: Ulid::from_string("01FSHN9AG0MZAA6S4AF7CTV32E").unwrap(), |
| 941 | + human_name: "CI/CD Pipeline Token".to_owned(), |
| 942 | + scope: "openid urn:mas:admin".to_owned(), |
| 943 | + last_active_at: Some(DateTime::from_timestamp(1_642_348_000, 0).unwrap()), /* 2022-01-16T17:26:40Z */ |
| 944 | + last_active_ip: Some("203.0.113.10".parse().unwrap()), |
| 945 | + expires_at: Some(DateTime::from_timestamp(1_642_999_000, 0).unwrap()), |
| 946 | + access_token: None, |
| 947 | + }, |
| 948 | + ] |
| 949 | + } |
| 950 | + |
| 951 | + /// Add the actual token value (for use in creation responses) |
| 952 | + pub fn with_token(mut self, access_token: String) -> Self { |
| 953 | + self.access_token = Some(access_token); |
| 954 | + self |
| 955 | + } |
| 956 | +} |
0 commit comments