Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Organizations and membership (M5).
2//!
3//! Orgs share the handle namespace with users — enforced by a trigger in the
4//! schema, not here — so `/{handle}` stays unambiguous and an org cannot be
5//! created that shadows a person.
6//!
7//! Org membership grants repository access through `permissions::resolve`:
8//! admins get repo `admin`, members get `read`. Nothing in this module makes a
9//! permission decision of its own.
10
11use axum::extract::{Path as UrlPath, Query, State};
12use axum::response::{IntoResponse, Redirect, Response};
13use axum::Form;
14use df_db::ids::new_id;
15use df_db::models::OrgRole;
16use serde::Deserialize;
17use uuid::Uuid;
18
19use crate::error::{AppError, AppResult};
20use crate::routes::settings::{audit, urlencode};
21use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
22use crate::views::{self, Chrome};
23
24#[derive(Deserialize, Default)]
25pub struct Flash {
26 pub error: Option<String>,
27 pub notice: Option<String>,
28}
29
30/// `GET /orgs/new`
31pub async fn new_form(
32 Query(flash): Query<Flash>,
33 CurrentUser(user): CurrentUser,
34 CsrfToken(csrf): CsrfToken,
35 Nonce(nonce): Nonce,
36) -> AppResult<Response> {
37 let Some(user) = user else {
38 return Err(AppError::Unauthorized);
39 };
40
41 Ok(views::page(
42 Chrome { title: "New organization", user: Some(&user), csrf: &csrf, nonce: &nonce },
43 maud::html! {
44 div .panel {
45 h1 { "New organization" }
46 p .lede {
47 "An organization owns repositories on behalf of a group. You become \
48 its first administrator."
49 }
50 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
51
52 form method="post" action="/orgs" .stack {
53 input type="hidden" name="_csrf" value=(csrf);
54 div .field {
55 label for="handle" { "Handle" }
56 input type="text" id="handle" name="handle" required
57 pattern="[a-z0-9][a-z0-9-]*" maxlength="39" autofocus;
58 p .hint {
59 "Appears in URLs, like " code { "dogfood.sh/your-org/repo" } ". \
60 Shares a namespace with user handles."
61 }
62 }
63 div .field {
64 label for="display_name" { "Display name" }
65 input type="text" id="display_name" name="display_name" maxlength="100";
66 }
67 div .field {
68 label for="description" { "Description" }
69 input type="text" id="description" name="description" maxlength="500";
70 }
71 button .btn.btn-primary type="submit" { "Create organization" }
72 }
73 }
74 },
75 )
76 .into_response())
77}
78
79#[derive(Deserialize)]
80pub struct CreateOrg {
81 pub handle: String,
82 pub display_name: Option<String>,
83 pub description: Option<String>,
84}
85
86/// `POST /orgs`
87pub async fn create(
88 State(state): State<AppState>,
89 CurrentUser(user): CurrentUser,
90 Form(form): Form<CreateOrg>,
91) -> AppResult<Response> {
92 let Some(user) = user else {
93 return Err(AppError::Unauthorized);
94 };
95
96 let handle = form.handle.trim().to_lowercase();
97 if !valid_handle(&handle) {
98 return Ok(reject("Handles are lowercase letters, digits and hyphens, up to 39 characters."));
99 }
100
101 // Reserved handles are top-level routes; one held by an org would shadow
102 // them (spec §7 puts /settings, /new and /search at the root).
103 let reserved: bool =
104 sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM reserved_handles WHERE handle = $1)")
105 .bind(&handle)
106 .fetch_one(&state.db)
107 .await?;
108 if reserved {
109 return Ok(reject("That handle is reserved."));
110 }
111
112 let org_id = new_id();
113 let mut tx = state.db.begin().await?;
114
115 // The unique constraint and the namespace trigger both surface as a unique
116 // violation; either way the handle is taken, and saying which would leak
117 // whether a private account exists.
118 let inserted = sqlx::query(
119 "INSERT INTO orgs (id, handle, display_name, description)
120 VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
121 )
122 .bind(org_id)
123 .bind(&handle)
124 .bind(form.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty()))
125 .bind(form.description.as_deref().map(str::trim).filter(|s| !s.is_empty()))
126 .execute(&mut *tx)
127 .await;
128
129 match inserted {
130 Ok(r) if r.rows_affected() == 0 => return Ok(reject("That handle is taken.")),
131 Err(e) if is_unique_violation(&e) => return Ok(reject("That handle is taken.")),
132 Err(e) => return Err(e.into()),
133 Ok(_) => {}
134 }
135
136 sqlx::query("INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'admin')")
137 .bind(org_id)
138 .bind(user.id)
139 .execute(&mut *tx)
140 .await?;
141
142 tx.commit().await?;
143
144 audit(&state, user.id, "org.created", &handle).await;
145
146 Ok(Redirect::to(&format!("/{handle}")).into_response())
147}
148
149/// `GET /{handle}/-/members` — org membership management.
150///
151/// Under `/-/` rather than `/{handle}/members` so it cannot collide with a
152/// repository called `members`.
153pub async fn members(
154 State(state): State<AppState>,
155 UrlPath(handle): UrlPath<String>,
156 Query(flash): Query<Flash>,
157 CurrentUser(user): CurrentUser,
158 CsrfToken(csrf): CsrfToken,
159 Nonce(nonce): Nonce,
160) -> AppResult<Response> {
161 let Some(user) = user else {
162 return Err(AppError::Unauthorized);
163 };
164
165 let org = load_org(&state, &handle).await?.ok_or(AppError::NotFound)?;
166 let role = member_role(&state, org.id, user.id).await?;
167
168 // Not a member: the same 404 a nonexistent org gets. Membership of an org
169 // is not public information here.
170 if role.is_none() && !user.is_admin {
171 return Err(AppError::NotFound);
172 }
173 let is_admin = user.is_admin || role == Some(OrgRole::Admin);
174
175 let rows: Vec<(String, OrgRole)> = sqlx::query_as(
176 "SELECT u.handle::text, m.role FROM org_members m JOIN users u ON u.id = m.user_id
177 WHERE m.org_id = $1 ORDER BY m.role DESC, u.handle",
178 )
179 .bind(org.id)
180 .fetch_all(&state.db)
181 .await?;
182
183 Ok(views::page(
184 Chrome {
185 title: &format!("{handle} members"),
186 user: Some(&user),
187 csrf: &csrf,
188 nonce: &nonce,
189 },
190 maud::html! {
191 div .panel {
192 h1 { (org.display_name.as_deref().unwrap_or(&org.handle)) }
193 p .dim { "@" (org.handle) " · organization" }
194 p { a href=(format!("/{handle}")) { "Back to the profile" } }
195 }
196
197 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
198 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
199
200 div .panel {
201 h2 { "Members" }
202 table .listing {
203 thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } }
204 tbody {
205 @for (h, r) in &rows {
206 tr {
207 td { a href=(format!("/{h}")) { (h) } }
208 td { (role_str(*r)) }
209 td {
210 @if is_admin {
211 form method="post"
212 action=(format!("/{handle}/-/members/remove")) {
213 input type="hidden" name="_csrf" value=(csrf);
214 input type="hidden" name="handle" value=(h);
215 button .btn.btn-danger type="submit" { "Remove" }
216 }
217 }
218 }
219 }
220 }
221 }
222 }
223
224 @if is_admin {
225 form method="post" action=(format!("/{handle}/-/members")) .stack {
226 input type="hidden" name="_csrf" value=(csrf);
227 div .field {
228 label for="member" { "Add a member" }
229 input type="text" id="member" name="handle" required
230 pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle";
231 }
232 div .field {
233 label for="role" { "Role" }
234 select id="role" name="role" {
235 option value="member" selected { "member — read access to org repositories" }
236 option value="admin" { "admin — full access, and can manage membership" }
237 }
238 }
239 button .btn.btn-primary type="submit" { "Add" }
240 }
241 }
242 }
243 },
244 )
245 .into_response())
246}
247
248#[derive(Deserialize)]
249pub struct MemberForm {
250 pub handle: String,
251 pub role: Option<String>,
252}
253
254/// `POST /{handle}/-/members`
255pub async fn add_member(
256 State(state): State<AppState>,
257 UrlPath(handle): UrlPath<String>,
258 CurrentUser(user): CurrentUser,
259 Form(form): Form<MemberForm>,
260) -> AppResult<Response> {
261 let (org, user) = require_org_admin(&state, &handle, user).await?;
262
263 let role = match form.role.as_deref() {
264 Some("admin") => "admin",
265 _ => "member",
266 };
267
268 let target: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
269 .bind(form.handle.trim())
270 .fetch_optional(&state.db)
271 .await?;
272
273 let Some((target_id,)) = target else {
274 return Ok(member_err(&handle, "No such user."));
275 };
276
277 sqlx::query(
278 "INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, $3::org_role)
279 ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role",
280 )
281 .bind(org.id)
282 .bind(target_id)
283 .bind(role)
284 .execute(&state.db)
285 .await?;
286
287 audit(
288 &state,
289 user.id,
290 "org.member_added",
291 &format!("{handle} {} = {role}", form.handle.trim()),
292 )
293 .await;
294
295 Ok(member_ok(&handle, "Member added."))
296}
297
298/// `POST /{handle}/-/members/remove`
299pub async fn remove_member(
300 State(state): State<AppState>,
301 UrlPath(handle): UrlPath<String>,
302 CurrentUser(user): CurrentUser,
303 Form(form): Form<MemberForm>,
304) -> AppResult<Response> {
305 let (org, user) = require_org_admin(&state, &handle, user).await?;
306
307 // An org with no administrator cannot be administered again — there is no
308 // site-wide "adopt this org" path — so the last one cannot leave.
309 let admins: i64 = sqlx::query_scalar(
310 "SELECT count(*) FROM org_members WHERE org_id = $1 AND role = 'admin'",
311 )
312 .bind(org.id)
313 .fetch_one(&state.db)
314 .await?;
315
316 let target_is_admin: bool = sqlx::query_scalar(
317 "SELECT EXISTS (SELECT 1 FROM org_members m JOIN users u ON u.id = m.user_id
318 WHERE m.org_id = $1 AND u.handle = $2 AND m.role = 'admin')",
319 )
320 .bind(org.id)
321 .bind(form.handle.trim())
322 .fetch_one(&state.db)
323 .await?;
324
325 if target_is_admin && admins <= 1 {
326 return Ok(member_err(
327 &handle,
328 "That is the last administrator; promote somebody else first.",
329 ));
330 }
331
332 sqlx::query(
333 "DELETE FROM org_members
334 WHERE org_id = $1 AND user_id = (SELECT id FROM users WHERE handle = $2)",
335 )
336 .bind(org.id)
337 .bind(form.handle.trim())
338 .execute(&state.db)
339 .await?;
340
341 audit(
342 &state,
343 user.id,
344 "org.member_removed",
345 &format!("{handle} {}", form.handle.trim()),
346 )
347 .await;
348
349 Ok(member_ok(&handle, "Member removed."))
350}
351
352// ─── helpers ─────────────────────────────────────────────────────────────────
353
354pub struct Org {
355 pub id: Uuid,
356 pub handle: String,
357 pub display_name: Option<String>,
358}
359
360async fn load_org(state: &AppState, handle: &str) -> AppResult<Option<Org>> {
361 let row: Option<(Uuid, String, Option<String>)> =
362 sqlx::query_as("SELECT id, handle::text, display_name FROM orgs WHERE handle = $1")
363 .bind(handle)
364 .fetch_optional(&state.db)
365 .await?;
366 Ok(row.map(|(id, handle, display_name)| Org { id, handle, display_name }))
367}
368
369async fn member_role(state: &AppState, org_id: Uuid, user_id: Uuid) -> AppResult<Option<OrgRole>> {
370 Ok(
371 sqlx::query_scalar("SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2")
372 .bind(org_id)
373 .bind(user_id)
374 .fetch_optional(&state.db)
375 .await?,
376 )
377}
378
379/// Resolve the org and require the viewer to administer it.
380///
381/// Returns the same 404 for "no such org" and "you are not a member" — org
382/// membership is not public here, so a 403 would disclose that the org exists.
383async fn require_org_admin(
384 state: &AppState,
385 handle: &str,
386 user: Option<std::sync::Arc<df_db::models::User>>,
387) -> AppResult<(Org, std::sync::Arc<df_db::models::User>)> {
388 let Some(user) = user else {
389 return Err(AppError::Unauthorized);
390 };
391 let org = load_org(state, handle).await?.ok_or(AppError::NotFound)?;
392 let role = member_role(state, org.id, user.id).await?;
393
394 if role.is_none() && !user.is_admin {
395 return Err(AppError::NotFound);
396 }
397 if role != Some(OrgRole::Admin) && !user.is_admin {
398 return Err(AppError::Forbidden);
399 }
400 Ok((org, user))
401}
402
403fn valid_handle(h: &str) -> bool {
404 !h.is_empty()
405 && h.len() <= 39
406 && h.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
407 && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
408}
409
410fn is_unique_violation(e: &sqlx::Error) -> bool {
411 matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
412}
413
414fn role_str(r: OrgRole) -> &'static str {
415 match r {
416 OrgRole::Member => "member",
417 OrgRole::Admin => "admin",
418 }
419}
420
421fn reject(msg: &str) -> Response {
422 Redirect::to(&format!("/orgs/new?error={}", urlencode(msg))).into_response()
423}
424
425fn member_ok(handle: &str, msg: &str) -> Response {
426 Redirect::to(&format!("/{handle}/-/members?notice={}", urlencode(msg))).into_response()
427}
428
429fn member_err(handle: &str, msg: &str) -> Response {
430 Redirect::to(&format!("/{handle}/-/members?error={}", urlencode(msg))).into_response()
431}
432
433#[cfg(test)]
434mod tests {
435 use super::valid_handle;
436
437 #[test]
438 fn handle_validation_matches_the_database_constraint() {
439 assert!(valid_handle("acme"));
440 assert!(valid_handle("acme-corp"));
441 assert!(valid_handle("9lives"));
442
443 assert!(!valid_handle(""));
444 assert!(!valid_handle("-leading"));
445 assert!(!valid_handle("Upper"));
446 assert!(!valid_handle("with space"));
447 assert!(!valid_handle("under_score"));
448 assert!(!valid_handle(&"a".repeat(40)));
449 }
450}

450 lines · Rust