Link removal storage API

From #3245 with changes from review
This commit is contained in:
MTRNord
2025-03-17 16:29:44 +00:00
committed by Tonkku
parent 81c0602092
commit 1ab402c7bf
9 changed files with 175 additions and 16 deletions

View File

@@ -30,6 +30,12 @@ pub enum UpstreamOAuthAuthorizationSessionState {
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
},
Unlinked {
completed_at: DateTime<Utc>,
consumed_at: Option<DateTime<Utc>>,
unlinked_at: DateTime<Utc>,
id_token: Option<String>,
},
}
impl UpstreamOAuthAuthorizationSessionState {
@@ -57,7 +63,9 @@ impl UpstreamOAuthAuthorizationSessionState {
extra_callback_parameters,
userinfo,
}),
Self::Completed { .. } | Self::Consumed { .. } => Err(InvalidTransitionError),
Self::Completed { .. } | Self::Consumed { .. } | Self::Unlinked { .. } => {
Err(InvalidTransitionError)
}
}
}
@@ -85,7 +93,9 @@ impl UpstreamOAuthAuthorizationSessionState {
extra_callback_parameters,
userinfo,
}),
Self::Pending | Self::Consumed { .. } => Err(InvalidTransitionError),
Self::Pending | Self::Consumed { .. } | Self::Unlinked { .. } => {
Err(InvalidTransitionError)
}
}
}
@@ -98,7 +108,7 @@ impl UpstreamOAuthAuthorizationSessionState {
#[must_use]
pub fn link_id(&self) -> Option<Ulid> {
match self {
Self::Pending => None,
Self::Pending | Self::Unlinked { .. } => None,
Self::Completed { link_id, .. } | Self::Consumed { link_id, .. } => Some(*link_id),
}
}
@@ -114,9 +124,9 @@ impl UpstreamOAuthAuthorizationSessionState {
pub fn completed_at(&self) -> Option<DateTime<Utc>> {
match self {
Self::Pending => None,
Self::Completed { completed_at, .. } | Self::Consumed { completed_at, .. } => {
Some(*completed_at)
}
Self::Completed { completed_at, .. }
| Self::Consumed { completed_at, .. }
| Self::Unlinked { completed_at, .. } => Some(*completed_at),
}
}
@@ -130,9 +140,9 @@ impl UpstreamOAuthAuthorizationSessionState {
pub fn id_token(&self) -> Option<&str> {
match self {
Self::Pending => None,
Self::Completed { id_token, .. } | Self::Consumed { id_token, .. } => {
id_token.as_deref()
}
Self::Completed { id_token, .. }
| Self::Consumed { id_token, .. }
| Self::Unlinked { id_token, .. } => id_token.as_deref(),
}
}
@@ -145,7 +155,7 @@ impl UpstreamOAuthAuthorizationSessionState {
#[must_use]
pub fn extra_callback_parameters(&self) -> Option<&serde_json::Value> {
match self {
Self::Pending => None,
Self::Pending | Self::Unlinked { .. } => None,
Self::Completed {
extra_callback_parameters,
..
@@ -160,7 +170,7 @@ impl UpstreamOAuthAuthorizationSessionState {
#[must_use]
pub fn userinfo(&self) -> Option<&serde_json::Value> {
match self {
Self::Pending => None,
Self::Pending | Self::Unlinked { .. } => None,
Self::Completed { userinfo, .. } | Self::Consumed { userinfo, .. } => userinfo.as_ref(),
}
}
@@ -177,6 +187,22 @@ impl UpstreamOAuthAuthorizationSessionState {
match self {
Self::Pending | Self::Completed { .. } => None,
Self::Consumed { consumed_at, .. } => Some(*consumed_at),
Self::Unlinked { consumed_at, .. } => *consumed_at,
}
}
/// Get the time at which the upstream OAuth 2.0 authorization session was
/// unlinked.
///
/// Returns `None` if the upstream OAuth 2.0 authorization session state is
/// not [`Unlinked`].
///
/// [`Unlinked`]: UpstreamOAuthAuthorizationSessionState::Unlinked
#[must_use]
pub fn unlinked_at(&self) -> Option<DateTime<Utc>> {
match self {
Self::Pending | Self::Completed { .. } | Self::Consumed { .. } => None,
Self::Unlinked { unlinked_at, .. } => Some(*unlinked_at),
}
}
@@ -206,6 +232,15 @@ impl UpstreamOAuthAuthorizationSessionState {
pub fn is_consumed(&self) -> bool {
matches!(self, Self::Consumed { .. })
}
/// Returns `true` if the upstream OAuth 2.0 authorization session state is
/// [`Unlinked`].
///
/// [`Unlinked`]: UpstreamOAuthAuthorizationSessionState::Unlinked
#[must_use]
pub fn is_unlinked(&self) -> bool {
matches!(self, Self::Unlinked { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ",
"query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at,\n unlinked_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 11,
"name": "consumed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 12,
"name": "unlinked_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -81,8 +86,9 @@
true,
false,
true,
true,
true
]
},
"hash": "ea30b3809fd7c1d4e9983909c0219f343953a89f2a43f6b8c4ab4fbea7645ccc"
"hash": "37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE upstream_oauth_authorization_sessions SET\n upstream_oauth_link_id = NULL,\n unlinked_at = $2\n WHERE upstream_oauth_link_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "3ed73cfce8ef6a1108f454e18b1668f64b76975dba07e67d04ed7a52e2e8107f"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM upstream_oauth_links\n WHERE upstream_oauth_link_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "cc60ad934d347fb4546205d1fe07e9d2f127cb15b1bb650d1ea3805a4c55b196"
}

View File

@@ -23,7 +23,7 @@
"Left": []
},
"nullable": [
true,
false,
true,
null
]

View File

@@ -0,0 +1,7 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
ALTER TABLE upstream_oauth_authorization_sessions
ADD COLUMN unlinked_at TIMESTAMP WITH TIME ZONE;

View File

@@ -374,4 +374,53 @@ impl UpstreamOAuthLinkRepository for PgUpstreamOAuthLinkRepository<'_> {
.try_into()
.map_err(DatabaseError::to_invalid_operation)
}
#[tracing::instrument(
name = "db.upstream_oauth_link.remove",
skip_all,
fields(
db.query.text,
upstream_oauth_link.id,
upstream_oauth_link.provider_id,
%upstream_oauth_link.subject,
),
err,
)]
async fn remove(
&mut self,
clock: &dyn Clock,
upstream_oauth_link: UpstreamOAuthLink,
) -> Result<(), Self::Error> {
// Unlink the authorization sessions first, as they have a foreign key
// constraint on the links.
sqlx::query!(
r#"
UPDATE upstream_oauth_authorization_sessions SET
upstream_oauth_link_id = NULL,
unlinked_at = $2
WHERE upstream_oauth_link_id = $1
"#,
Uuid::from(upstream_oauth_link.id),
clock.now()
)
.traced()
.execute(&mut *self.conn)
.await?;
// Then delete the link itself
let res = sqlx::query!(
r#"
DELETE FROM upstream_oauth_links
WHERE upstream_oauth_link_id = $1
"#,
Uuid::from(upstream_oauth_link.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
Ok(())
}
}

View File

@@ -45,6 +45,7 @@ struct SessionLookup {
completed_at: Option<DateTime<Utc>>,
consumed_at: Option<DateTime<Utc>>,
extra_callback_parameters: Option<serde_json::Value>,
unlinked_at: Option<DateTime<Utc>>,
}
impl TryFrom<SessionLookup> for UpstreamOAuthAuthorizationSession {
@@ -59,8 +60,11 @@ impl TryFrom<SessionLookup> for UpstreamOAuthAuthorizationSession {
value.userinfo,
value.completed_at,
value.consumed_at,
value.unlinked_at,
) {
(None, None, None, None, None, None) => UpstreamOAuthAuthorizationSessionState::Pending,
(None, None, None, None, None, None, None) => {
UpstreamOAuthAuthorizationSessionState::Pending
}
(
Some(link_id),
id_token,
@@ -68,6 +72,7 @@ impl TryFrom<SessionLookup> for UpstreamOAuthAuthorizationSession {
userinfo,
Some(completed_at),
None,
None,
) => UpstreamOAuthAuthorizationSessionState::Completed {
completed_at,
link_id: link_id.into(),
@@ -82,6 +87,7 @@ impl TryFrom<SessionLookup> for UpstreamOAuthAuthorizationSession {
userinfo,
Some(completed_at),
Some(consumed_at),
None,
) => UpstreamOAuthAuthorizationSessionState::Consumed {
completed_at,
link_id: link_id.into(),
@@ -90,6 +96,14 @@ impl TryFrom<SessionLookup> for UpstreamOAuthAuthorizationSession {
userinfo,
consumed_at,
},
(_, id_token, _, _, Some(completed_at), consumed_at, Some(unlinked_at)) => {
UpstreamOAuthAuthorizationSessionState::Unlinked {
completed_at,
id_token,
consumed_at,
unlinked_at,
}
}
_ => {
return Err(DatabaseInconsistencyError::on(
"upstream_oauth_authorization_sessions",
@@ -142,7 +156,8 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> {
userinfo,
created_at,
completed_at,
consumed_at
consumed_at,
unlinked_at
FROM upstream_oauth_authorization_sessions
WHERE upstream_oauth_authorization_session_id = $1
"#,

View File

@@ -200,6 +200,22 @@ pub trait UpstreamOAuthLinkRepository: Send + Sync {
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn count(&mut self, filter: UpstreamOAuthLinkFilter<'_>) -> Result<usize, Self::Error>;
/// Delete a [`UpstreamOAuthLink`]
///
/// # Parameters
///
/// * `clock`: The clock used to generate timestamps
/// * `upstream_oauth_link`: The [`UpstreamOAuthLink`] to delete
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn remove(
&mut self,
clock: &dyn Clock,
upstream_oauth_link: UpstreamOAuthLink,
) -> Result<(), Self::Error>;
}
repository_impl!(UpstreamOAuthLinkRepository:
@@ -233,4 +249,6 @@ repository_impl!(UpstreamOAuthLinkRepository:
) -> Result<Page<UpstreamOAuthLink>, Self::Error>;
async fn count(&mut self, filter: UpstreamOAuthLinkFilter<'_>) -> Result<usize, Self::Error>;
async fn remove(&mut self, clock: &dyn Clock, upstream_oauth_link: UpstreamOAuthLink) -> Result<(), Self::Error>;
);