Skip to main content

max / makenotwork

7.3 KB · 216 lines History Blame Raw
1 //! SyncKit group invitations: the one-link onboarding path into a group.
2 //!
3 //! Adding a member directly (`add_or_update_member`) needs the admin to already
4 //! hold the member's account email and their pasted identity public key. An
5 //! invitation removes that exchange: the admin issues a token, the invitee posts
6 //! their public key against it, and the admin seals the grant after confirming
7 //! the key's fingerprint.
8 //!
9 //! Three properties this module is responsible for:
10 //!
11 //! 1. **The token is never stored.** Every lookup is by SHA-256 of the token, so
12 //! a read of the table (or of a backup) yields nothing redeemable.
13 //! 2. **Acceptance is one-shot.** [`accept_invitation`] is a single conditional
14 //! UPDATE, so two clients racing the same token produce one winner and one
15 //! `None` rather than two members.
16 //! 3. **Acceptance is not membership.** Accepting records a public key and
17 //! nothing else. Only the admin's later seal (via the normal add-member path,
18 //! followed by [`redeem_invitation`]) grants access.
19 //!
20 //! Design: wiki synckit-groups-design.
21
22 use chrono::{DateTime, Utc};
23 use sqlx::PgPool;
24
25 use crate::db::models::DbSyncGroupInvitation;
26 use crate::db::{SyncGroupId, SyncGroupInvitationId, UserId};
27 use crate::error::Result;
28
29 /// Every column the model needs, with the invitee's email joined in. The join is
30 /// LEFT because an outstanding invitation names nobody yet.
31 const SELECT_INVITATION: &str = r"
32 SELECT i.id, i.group_id, i.inviter_user_id, i.invitee_user_id,
33 u.email AS invitee_email, i.invitee_pubkey,
34 i.accepted_at, i.redeemed_at, i.revoked_at, i.expires_at, i.created_at
35 FROM sync_group_invitations i
36 LEFT JOIN users u ON u.id = i.invitee_user_id
37 ";
38
39 /// Issue an invitation. The caller generates the token, hashes it, and keeps the
40 /// plaintext for the link; only the hash arrives here.
41 #[tracing::instrument(skip_all)]
42 pub async fn create_invitation(
43 pool: &PgPool,
44 group_id: SyncGroupId,
45 inviter_user_id: UserId,
46 token_hash: &str,
47 expires_at: DateTime<Utc>,
48 ) -> Result<DbSyncGroupInvitation> {
49 let id: SyncGroupInvitationId = sqlx::query_scalar(
50 r"
51 INSERT INTO sync_group_invitations
52 (group_id, inviter_user_id, token_hash, expires_at)
53 VALUES ($1, $2, $3, $4)
54 RETURNING id
55 ",
56 )
57 .bind(group_id)
58 .bind(inviter_user_id)
59 .bind(token_hash)
60 .bind(expires_at)
61 .fetch_one(pool)
62 .await?;
63
64 // Re-read through the shared projection rather than RETURNING the columns
65 // directly: the model carries a joined email, and a second shape of this
66 // query is a second thing to keep in step.
67 get_invitation(pool, id)
68 .await?
69 .ok_or_else(|| sqlx::Error::RowNotFound.into())
70 }
71
72 /// Fetch one invitation by id.
73 #[tracing::instrument(skip_all)]
74 pub async fn get_invitation(
75 pool: &PgPool,
76 id: SyncGroupInvitationId,
77 ) -> Result<Option<DbSyncGroupInvitation>> {
78 let sql = format!("{SELECT_INVITATION} WHERE i.id = $1");
79 let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
80 .bind(id)
81 .fetch_optional(pool)
82 .await?;
83 Ok(invitation)
84 }
85
86 /// Fetch one invitation by the SHA-256 of its token, whatever state it is in.
87 ///
88 /// Used to show the invitee what they are about to join before they accept.
89 /// Deliberately returns expired, revoked and redeemed rows too, so the caller can
90 /// tell the invitee *why* a link does not work rather than answering "no such
91 /// invitation" to a link that plainly exists.
92 #[tracing::instrument(skip_all)]
93 pub async fn get_invitation_by_token(
94 pool: &PgPool,
95 token_hash: &str,
96 ) -> Result<Option<DbSyncGroupInvitation>> {
97 let sql = format!("{SELECT_INVITATION} WHERE i.token_hash = $1");
98 let invitation = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
99 .bind(token_hash)
100 .fetch_optional(pool)
101 .await?;
102 Ok(invitation)
103 }
104
105 /// Record the invitee's acceptance against a token: their account and their
106 /// identity public key.
107 ///
108 /// One statement, and the predicate carries every liveness condition, so
109 /// consuming a token is atomic. Two devices redeeming the same link concurrently
110 /// yield one `Some` and one `None`; a token that is expired, revoked, redeemed,
111 /// or already accepted yields `None` without the caller needing to have read the
112 /// row first. Callers distinguish "no such token" from "not live" with
113 /// [`get_invitation_by_token`].
114 ///
115 /// Acceptance grants nothing. It records the key the admin will seal to.
116 #[tracing::instrument(skip_all)]
117 pub async fn accept_invitation(
118 pool: &PgPool,
119 token_hash: &str,
120 invitee_user_id: UserId,
121 invitee_pubkey: &str,
122 ) -> Result<Option<DbSyncGroupInvitation>> {
123 let id: Option<SyncGroupInvitationId> = sqlx::query_scalar(
124 r"
125 UPDATE sync_group_invitations
126 SET invitee_user_id = $2,
127 invitee_pubkey = $3,
128 accepted_at = NOW()
129 WHERE token_hash = $1
130 AND accepted_at IS NULL
131 AND redeemed_at IS NULL
132 AND revoked_at IS NULL
133 AND expires_at > NOW()
134 RETURNING id
135 ",
136 )
137 .bind(token_hash)
138 .bind(invitee_user_id)
139 .bind(invitee_pubkey)
140 .fetch_optional(pool)
141 .await?;
142
143 match id {
144 Some(id) => get_invitation(pool, id).await,
145 None => Ok(None),
146 }
147 }
148
149 /// List a group's invitations, newest first. The admin's pending panel.
150 #[tracing::instrument(skip_all)]
151 pub async fn list_invitations(
152 pool: &PgPool,
153 group_id: SyncGroupId,
154 ) -> Result<Vec<DbSyncGroupInvitation>> {
155 let sql = format!("{SELECT_INVITATION} WHERE i.group_id = $1 ORDER BY i.created_at DESC");
156 let invitations = sqlx::query_as::<_, DbSyncGroupInvitation>(&sql)
157 .bind(group_id)
158 .fetch_all(pool)
159 .await?;
160 Ok(invitations)
161 }
162
163 /// Mark an accepted invitation as redeemed, once the admin has sealed the grant.
164 /// Returns `false` if it was already redeemed or revoked, which is how a
165 /// double-confirm is caught.
166 ///
167 /// The `group_id` is part of the predicate so an id from another group cannot be
168 /// closed by an admin who does not own it.
169 #[tracing::instrument(skip_all)]
170 pub async fn redeem_invitation(
171 pool: &PgPool,
172 group_id: SyncGroupId,
173 id: SyncGroupInvitationId,
174 ) -> Result<bool> {
175 let result = sqlx::query(
176 r"
177 UPDATE sync_group_invitations
178 SET redeemed_at = NOW()
179 WHERE id = $1 AND group_id = $2
180 AND accepted_at IS NOT NULL
181 AND redeemed_at IS NULL
182 AND revoked_at IS NULL
183 ",
184 )
185 .bind(id)
186 .bind(group_id)
187 .execute(pool)
188 .await?;
189 Ok(result.rows_affected() > 0)
190 }
191
192 /// Cancel an invitation. Works whether or not it has been accepted: an admin who
193 /// does not recognise the fingerprint needs to be able to throw it away, and that
194 /// is the case that matters most.
195 #[tracing::instrument(skip_all)]
196 pub async fn revoke_invitation(
197 pool: &PgPool,
198 group_id: SyncGroupId,
199 id: SyncGroupInvitationId,
200 ) -> Result<bool> {
201 let result = sqlx::query(
202 r"
203 UPDATE sync_group_invitations
204 SET revoked_at = NOW()
205 WHERE id = $1 AND group_id = $2
206 AND redeemed_at IS NULL
207 AND revoked_at IS NULL
208 ",
209 )
210 .bind(id)
211 .bind(group_id)
212 .execute(pool)
213 .await?;
214 Ok(result.rows_affected() > 0)
215 }
216