OXYGENHUB
# d2c1d6a7d12e oxygenhub / crates / hub / src / web / orgs.rs
19.4 KiB · Rust · last changed Secrets for pipelines, mounted as files 1 month ago source blame history raw
1
//! Organisations and teams (hub spec §5.2).
2
3
use super::settings::{csrf, require};
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{roles, Viewer};
6
use crate::db::accounts;
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Path, State};
10
use axum::response::{IntoResponse, Redirect, Response};
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "org_new.html")]
15
pub struct OrgNewPage {
16
    shell: Shell,
17
    error: Option<String>,
18
}
19
20
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<OrgNewPage>, Error> {
21
    let shell = Shell::new(&app, "/orgs/new", &viewer);
22
    require(&viewer, &shell)?;
23
    Ok(Page(OrgNewPage { shell, error: None }))
24
}
25
26
#[derive(Deserialize)]
27
pub struct OrgNewForm {
28
    #[serde(rename = "_csrf")]
29
    csrf_token: String,
30
    name: String,
31
    display_name: String,
32
}
33
34
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(f): Form<OrgNewForm>) -> Result<Response, Error> {
35
    let shell = Shell::new(&app, "/orgs/new", &viewer);
36
    let me = require(&viewer, &shell)?;
37
    csrf(&viewer, &f.csrf_token, &shell)?;
38
    let name = f.name.trim().to_string();
39
    if !crate::repos::names::valid_owner(&name) {
40
        return Ok(Page(OrgNewPage {
41
            shell,
42
            error: Some("An organisation name is letters, digits, dots, dashes and underscores.".into()),
43
        })
44
        .into_response());
45
    }
46
    let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
47
        || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
48
        || accounts::username_retired(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
49
    if taken {
50
        return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response());
51
    }
52
    let org =
53
        accounts::create_org(&app.db, &name, f.display_name.trim(), me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
54
    accounts::audit(&app.db, Some(&me.account), "org.created", &org.name, "").await.ok();
55
    Ok(Redirect::to(&format!("/orgs/{}/settings", org.name)).into_response())
56
}
57
58
#[derive(Template)]
59
#[template(path = "org_settings.html")]
60
pub struct OrgSettingsPage {
61
    shell: Shell,
62
    org: accounts::Organisation,
63
    members: Vec<(accounts::Account, String)>,
64
    teams: Vec<(accounts::Team, Vec<accounts::Account>)>,
65
    hooks: Vec<super::webhooks::HookRow>,
66
    notice: Option<String>,
67
    error: Option<String>,
68
    /// Runners serving the organisation: (runner, what it serves, whether
69
    /// it is the organisation's own).
70
    runners: Vec<(crate::db::runners::Runner, String, bool)>,
71
    /// A registration token just made, shown once.
72
    runner_token: Option<String>,
73
    /// The organisation's AI assistants and prompt (#97).
74
    assistants: super::assistants::Panel,
75
    /// The organisation's Flow variables (#102).
76
    variables: super::variables::Panel,
77
    /// The organisation's Flow secrets (#107).
78
    secrets: super::secrets::Panel,
79
}
80
81
async fn org_page(
82
    app: &Shared,
83
    shell: Shell,
84
    org: accounts::Organisation,
85
    notice: Option<String>,
86
    error: Option<String>,
87
) -> Result<Page<OrgSettingsPage>, Error> {
88
    let members = accounts::org_members(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
89
    let mut teams = Vec::new();
90
    for t in accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
91
        let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
92
        teams.push((t, members));
93
    }
94
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
95
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
96
    let mut runners = Vec::new();
97
    for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? {
98
        let own = r.scope_owner == org.name;
99
        let serves = match r.scope_repo_id {
100
            Some(id) => crate::db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(),
101
            None if own => "the organisation's repositories".into(),
102
            None => "the whole site".into(),
103
        };
104
        runners.push((r, serves, own));
105
    }
106
    let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
107
    let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
108
    let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109
    Ok(Page(OrgSettingsPage {
110
        shell,
111
        org,
112
        members,
113
        teams,
114
        hooks,
115
        notice,
116
        error,
117
        runners,
118
        runner_token: None,
119
        assistants,
120
        variables,
121
        secrets,
122
    }))
123
}
124
125
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
126
    let me = require(viewer, shell)?;
127
    let org = accounts::org_by_name(&app.db, name)
128
        .await
129
        .map_err(|e| Error::from_db(shell.clone(), e))?
130
        .ok_or_else(|| Error::not_found(shell.clone()))?;
131
    if !roles::is_org_owner(&app.db, &me.account, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
132
        return Err(Error::not_found(shell.clone()));
133
    }
134
    Ok((me.account, org))
135
}
136
137
pub async fn settings(State(app): State<Shared>, viewer: Viewer, Path(name): Path<String>) -> Result<Page<OrgSettingsPage>, Error> {
138
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
139
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
140
    org_page(&app, shell, org, None, None).await
141
}
142
143
#[derive(Deserialize)]
144
pub struct OrgForm {
145
    #[serde(rename = "_csrf")]
146
    csrf_token: String,
147
    display_name: String,
148
    description: String,
149
    default_role: String,
150
}
151
152
pub async fn update(
153
    State(app): State<Shared>,
154
    viewer: Viewer,
155
    Path(name): Path<String>,
156
    Form(f): Form<OrgForm>,
157
) -> Result<Page<OrgSettingsPage>, Error> {
158
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
159
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
160
    csrf(&viewer, &f.csrf_token, &shell)?;
161
    let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" };
162
    accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role)
163
        .await
164
        .map_err(|e| Error::from_db(shell.clone(), e))?;
165
    accounts::audit(&app.db, Some(&me), "org.updated", &org.name, "").await.ok();
166
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
167
    org_page(&app, shell, org, Some("Saved.".into()), None).await
168
}
169
170
#[derive(Deserialize)]
171
pub struct MemberForm {
172
    #[serde(rename = "_csrf")]
173
    csrf_token: String,
174
    username: String,
175
    #[serde(default)]
176
    role: String,
177
    #[serde(default)]
178
    remove: Option<String>,
179
}
180
181
pub async fn member(
182
    State(app): State<Shared>,
183
    viewer: Viewer,
184
    Path(name): Path<String>,
185
    Form(f): Form<MemberForm>,
186
) -> Result<Page<OrgSettingsPage>, Error> {
187
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
188
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
189
    csrf(&viewer, &f.csrf_token, &shell)?;
190
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
191
        return org_page(&app, shell, org, None, Some("No such account.".into())).await;
192
    };
193
    if f.remove.is_some() {
194
        let owners = accounts::org_members(&app.db, org.id)
195
            .await
196
            .map_err(|e| Error::from_db(shell.clone(), e))?
197
            .iter()
198
            .filter(|(_, r)| r == "owner")
199
            .count();
200
        let is_owner =
201
            accounts::org_role(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.as_deref() == Some("owner");
202
        if is_owner && owners <= 1 {
203
            return org_page(&app, shell, org, None, Some("An organisation keeps at least one owner.".into())).await;
204
        }
205
        accounts::remove_org_member(&app.db, org.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
206
        accounts::audit(&app.db, Some(&me), "org.member.removed", &org.name, &target.username).await.ok();
207
        return org_page(&app, shell, org, Some(format!("Removed {}.", target.username)), None).await;
208
    }
209
    let role = if f.role == "owner" { "owner" } else { "member" };
210
    accounts::set_org_member(&app.db, org.id, target.id, role).await.map_err(|e| Error::from_db(shell.clone(), e))?;
211
    accounts::audit(&app.db, Some(&me), "org.member.set", &org.name, &format!("{} {role}", target.username)).await.ok();
212
    org_page(&app, shell, org, Some(format!("{} is now {role}.", target.username)), None).await
213
}
214
215
#[derive(Deserialize)]
216
pub struct TeamForm {
217
    #[serde(rename = "_csrf")]
218
    csrf_token: String,
219
    team: String,
220
    #[serde(default)]
221
    username: String,
222
    #[serde(default)]
223
    action: String,
224
}
225
226
pub async fn team(
227
    State(app): State<Shared>,
228
    viewer: Viewer,
229
    Path(name): Path<String>,
230
    Form(f): Form<TeamForm>,
231
) -> Result<Page<OrgSettingsPage>, Error> {
232
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
233
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
234
    csrf(&viewer, &f.csrf_token, &shell)?;
235
    let team_name = f.team.trim().to_string();
236
    if !crate::repos::names::valid_segment(&team_name) {
237
        return org_page(&app, shell, org, None, Some("A team name is letters, digits, dots, dashes and underscores.".into())).await;
238
    }
239
    let existing = accounts::team_by_name(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
240
    let notice = match f.action.as_str() {
241
        "create" => {
242
            if existing.is_some() {
243
                return org_page(&app, shell, org, None, Some("That team exists.".into())).await;
244
            }
245
            accounts::create_team(&app.db, org.id, &team_name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
246
            format!("Team {team_name} created.")
247
        }
248
        "delete" => {
249
            if let Some(t) = existing {
250
                accounts::delete_team(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
251
            }
252
            format!("Team {team_name} deleted.")
253
        }
254
        "add" | "remove" => {
255
            let Some(t) = existing else { return org_page(&app, shell, org, None, Some("No such team.".into())).await };
256
            let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?
257
            else {
258
                return org_page(&app, shell, org, None, Some("No such account.".into())).await;
259
            };
260
            if f.action == "add" {
261
                accounts::set_org_member(&app.db, org.id, target.id, "member").await.ok();
262
                accounts::add_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
263
                format!("{} added to {team_name}.", target.username)
264
            } else {
265
                accounts::remove_team_member(&app.db, t.id, target.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
266
                format!("{} removed from {team_name}.", target.username)
267
            }
268
        }
269
        _ => return Err(Error::bad(shell, "unknown team action")),
270
    };
271
    accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok();
272
    org_page(&app, shell, org, Some(notice), None).await
273
}
274
275
/// `POST /orgs/{name}/settings/avatar`: upload or remove the organisation's logo.
276
pub async fn avatar(
277
    State(app): State<Shared>,
278
    viewer: Viewer,
279
    Path(name): Path<String>,
280
    multipart: axum::extract::Multipart,
281
) -> Result<Page<OrgSettingsPage>, Error> {
282
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
283
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
284
    let upload = super::avatar::read_upload(multipart).await;
285
    csrf(&viewer, &upload.csrf, &shell)?;
286
    let result = super::avatar::apply(&app, "org", org.id, upload).await;
287
    if result.is_ok() {
288
        accounts::audit(&app.db, Some(&me), "org.picture", &org.name, "").await.ok();
289
    }
290
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
291
    match result {
292
        Ok(m) => org_page(&app, shell, org, Some(m), None).await,
293
        Err(e) => org_page(&app, shell, org, None, Some(e)).await,
294
    }
295
}
296
297
/// `POST /orgs/{name}/settings/hooks`: webhooks that hear every repository of the organisation.
298
pub async fn hooks(
299
    State(app): State<Shared>,
300
    viewer: Viewer,
301
    Path(name): Path<String>,
302
    Form(f): Form<super::webhooks::HookForm>,
303
) -> Result<Page<OrgSettingsPage>, Error> {
304
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
305
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
306
    csrf(&viewer, &f.csrf_token, &shell)?;
307
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name.clone() };
308
    match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
309
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
310
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
311
    }
312
}
313
314
pub async fn deliveries(
315
    State(app): State<Shared>,
316
    viewer: Viewer,
317
    Path((name, id)): Path<(String, i64)>,
318
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
319
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
320
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
321
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
322
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
323
}
324
325
pub async fn redeliver(
326
    State(app): State<Shared>,
327
    viewer: Viewer,
328
    Path((name, id, delivery)): Path<(String, i64, i64)>,
329
    Form(f): Form<super::session::CsrfForm>,
330
) -> Result<Page<super::webhooks::DeliveriesPage>, Error> {
331
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer);
332
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
333
    csrf(&viewer, &f.csrf, &shell)?;
334
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
335
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
336
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
337
}
338
339
/// `POST /orgs/{name}/settings/runners/token`: a one-time registration
340
/// token for a runner that serves the organisation's repositories
341
/// (oxygenhub#41).
342
pub async fn runner_token(
343
    State(app): State<Shared>,
344
    viewer: Viewer,
345
    Path(name): Path<String>,
346
    Form(f): Form<super::session::CsrfForm>,
347
) -> Result<Page<OrgSettingsPage>, Error> {
348
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
349
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
350
    csrf(&viewer, &f.csrf, &shell)?;
351
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
352
    let scope = crate::db::runners::Scope { owner: org.name.clone(), repo_id: None };
353
    crate::db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope)
354
        .await
355
        .map_err(|e| Error::from_db(shell.clone(), e))?;
356
    accounts::audit(&app.db, Some(&me), "flow.runner.token", &org.name, "registration token for the organisation").await.ok();
357
    let mut page = org_page(&app, shell, org, Some("Copy the token now; it is not shown again.".into()), None).await?;
358
    page.0.runner_token = Some(token);
359
    Ok(page)
360
}
361
362
/// `POST /orgs/{name}/settings/runners/{id}/remove`: remove a runner scoped
363
/// to the organisation or one of its repositories, when it runs nothing.
364
pub async fn runner_remove(
365
    State(app): State<Shared>,
366
    viewer: Viewer,
367
    Path((name, id)): Path<(String, i64)>,
368
    Form(f): Form<super::session::CsrfForm>,
369
) -> Result<Page<OrgSettingsPage>, Error> {
370
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
371
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
372
    csrf(&viewer, &f.csrf, &shell)?;
373
    let e = |e| Error::from_db(shell.clone(), e);
374
    let Some(r) = crate::db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_owner == org.name) else {
375
        return Err(Error::not_found(shell));
376
    };
377
    if crate::db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
378
        return org_page(&app, shell, org, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
379
    }
380
    crate::db::runners::remove(&app.db, id).await.map_err(e)?;
381
    accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok();
382
    org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
383
}
384
385
fn org_assistants(org: &accounts::Organisation) -> crate::levels::At {
386
    crate::levels::At::org(org)
387
}
388
389
/// `POST /orgs/{name}/settings/assistants`: the organisation's assistants
390
/// and prompt (#97).
391
pub async fn assistants(
392
    State(app): State<Shared>,
393
    viewer: Viewer,
394
    Path(name): Path<String>,
395
    Form(f): Form<super::assistants::AssistantForm>,
396
) -> Result<Page<OrgSettingsPage>, Error> {
397
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
398
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
399
    csrf(&viewer, &f.csrf_token, &shell)?;
400
    match super::assistants::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
401
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
402
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
403
    }
404
}
405
406
/// `POST /orgs/{name}/settings/variables`: the organisation's Flow
407
/// variables (#102).
408
pub async fn variables(
409
    State(app): State<Shared>,
410
    viewer: Viewer,
411
    Path(name): Path<String>,
412
    Form(f): Form<super::variables::VariableForm>,
413
) -> Result<Page<OrgSettingsPage>, Error> {
414
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
415
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
416
    csrf(&viewer, &f.csrf_token, &shell)?;
417
    match super::variables::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
418
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
419
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
420
    }
421
}
422
423
/// `POST /orgs/{name}/settings/secrets`: the organisation's Flow secrets (#107).
424
pub async fn secrets(
425
    State(app): State<Shared>,
426
    viewer: Viewer,
427
    Path(name): Path<String>,
428
    Form(f): Form<super::secrets::SecretForm>,
429
) -> Result<Page<OrgSettingsPage>, Error> {
430
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
431
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
432
    csrf(&viewer, &f.csrf_token, &shell)?;
433
    match super::secrets::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
434
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
435
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
436
    }
437
}