Skip to main content

didbot_serve/
dashboard_apps.rs

1//! The operations dashboard's per-app view: what each OAuth application
2//! holds on this server, and what it did.
3//!
4//! Part of [`crate::dashboard`] — surface 3 in `docs/web-surfaces.md`,
5//! behind the same operator session as every other control there — kept in
6//! its own file because it is its own panel group. Three reads, each
7//! filterable by `client_id` and by account, and the two logs by how far
8//! back:
9//!
10//! - **Live logins**, from the grant store. Beside each: what it may do
11//!   now, and whether policy would issue it a token now. Neither is
12//!   stored; both are asked of the ceiling and the gate at the moment the
13//!   panel is drawn, so a ceiling narrowed or a policy loaded a second ago
14//!   shows here.
15//! - **Refusals**, from the evaluation log.
16//! - **Writes**, from the write log.
17//!
18//! The two logs are read through their stores rather than out of the
19//! write-ahead log, and a store that cannot list its rows answers
20//! unreachable rather than empty: a deployment that keeps no log and one
21//! that refused nothing are different facts.
22//!
23//! Beside the reads there is one control: ending every login an
24//! application holds, here or on one account. It is the answer to an
25//! application whose keys leaked, and it is not the answer to an
26//! application that should not be here — that is a `denyClient` policy,
27//! written from surface 2, which stops the same logins working while it
28//! stands and lets them work again when it lifts. This ends them for good
29//! and says nothing about whether the application may sign in again.
30
31use axum::extract::{Query, State};
32use axum::http::StatusCode;
33use axum::response::{IntoResponse, Response};
34use axum::routing::{get, post};
35use axum::{Json, Router};
36use didbot_pds::evaluation_log::DenialRow;
37use didbot_pds::oauth::Grant;
38use didbot_pds::write_log::WriteRow;
39use serde::Serialize;
40use time::format_description::well_known::Rfc3339;
41use time::OffsetDateTime;
42
43use crate::dashboard::{refused_partway, Acted};
44use crate::oauth::token::{standing, GateAdmission};
45use crate::routes::AppState;
46use crate::surface::{acting_operator, refusal, signed_in, PanelState};
47
48/// Builds the per-app routes, merged into the dashboard's own router.
49pub fn routes() -> Router<AppState> {
50    Router::new()
51        .route("/dashboard/api/apps/logins", get(logins))
52        .route("/dashboard/api/apps/refusals", get(refusals))
53        .route("/dashboard/api/apps/writes", get(writes))
54        .route("/dashboard/api/apps/end-logins", post(end_logins))
55}
56
57/// What every route here narrows on. Both empty lists everything.
58#[derive(Debug, Clone, Default, serde::Deserialize)]
59#[serde(default)]
60pub struct AppFilter {
61    /// One application, by the `client_id` the rows spell.
62    client: Option<String>,
63    /// One account, by its handle or its `did:`.
64    account: Option<String>,
65    /// Only log rows from this long ago or later, as a span such as `7d`. A
66    /// live login carries no time to narrow on, so the logins read ignores
67    /// it.
68    since: Option<String>,
69}
70
71impl AppFilter {
72    /// Whether a row naming `client` and `account` passes. A row with no
73    /// client is the account writing with its own credential, which no
74    /// client filter matches.
75    fn admits(&self, client: Option<&str>, account: &str, resolved: Option<&str>) -> bool {
76        if let Some(wanted) = &self.client {
77            if client != Some(wanted.as_str()) {
78                return false;
79            }
80        }
81        match resolved {
82            Some(wanted) => wanted == account,
83            None => true,
84        }
85    }
86
87    /// The moment `since` names, or the refusal to answer with.
88    fn since(&self) -> Result<Option<OffsetDateTime>, Box<Response>> {
89        self.since
90            .as_deref()
91            .map(|span| crate::dashboard_accounts::span_before_now("since", span))
92            .transpose()
93    }
94
95    /// The account this filter names, as the rows spell it: its DID when
96    /// this deployment holds it, and otherwise what the operator typed —
97    /// so a row naming an account that has since gone is still findable.
98    fn account(&self, state: &AppState) -> Option<String> {
99        let named = self.account.as_deref()?;
100        Some(crate::routes::resolve_repo(state, named).unwrap_or_else(|_| named.to_owned()))
101    }
102}
103
104/// One live login, as the panel lists it.
105#[derive(Debug, Clone, Serialize)]
106#[serde(rename_all = "camelCase")]
107struct LoginView {
108    /// The grant family: the chain of pairs one approval has rotated into.
109    family: u64,
110    /// The account the login authenticates as.
111    account: String,
112    /// The application it was issued to.
113    client_id: String,
114    /// The scope the account approved.
115    approved_scope: String,
116    /// What the login may do now, under the ceiling in force.
117    scope_in_force: String,
118    /// Whether policy would issue this client a token for this account now.
119    admitted: bool,
120    /// The RFC 7638 thumbprint both tokens are bound to.
121    dpop_thumbprint: String,
122    /// When the current access token stops working.
123    access_expires_at: String,
124    /// When the login itself stops working.
125    refresh_expires_at: String,
126}
127
128impl LoginView {
129    fn of(grant: Grant, standing: crate::oauth::token::Standing) -> Self {
130        Self {
131            family: grant.family,
132            account: grant.did,
133            client_id: grant.client_id,
134            approved_scope: grant.scope,
135            scope_in_force: standing.in_force.to_string(),
136            admitted: standing.admitted,
137            dpop_thumbprint: grant.dpop_thumbprint,
138            access_expires_at: grant.access_expires_at.format(&Rfc3339).unwrap_or_default(),
139            refresh_expires_at: grant
140                .refresh_expires_at
141                .format(&Rfc3339)
142                .unwrap_or_default(),
143        }
144    }
145}
146
147/// `GET /dashboard/api/apps/logins`
148///
149/// Every live grant family, newest expiry last. What each may do now and
150/// whether policy still admits it are computed here rather than read: see
151/// [`crate::oauth::token::standing`].
152async fn logins(
153    State(state): State<AppState>,
154    headers: axum::http::HeaderMap,
155    Query(filter): Query<AppFilter>,
156) -> Response {
157    if let Err(refusal) = signed_in(&state, &headers, false) {
158        return *refusal;
159    }
160    let account = filter.account(&state);
161    let pds_did = crate::routes::deployment_did(&state);
162    let pds_hostname = state.registry.zone().to_owned();
163    let admission = GateAdmission {
164        gate: state.oauth.grant_gate.as_ref(),
165        registry: state.registry.as_ref(),
166        pds_did: &pds_did,
167        pds_hostname: &pds_hostname,
168    };
169    let ceiling = state.oauth.scope_policy.as_ref();
170    let mut rows: Vec<LoginView> = state
171        .oauth
172        .tokens
173        .live()
174        .into_iter()
175        .filter(|grant| filter.admits(Some(&grant.client_id), &grant.did, account.as_deref()))
176        .map(|grant| {
177            let standing = standing(&grant, ceiling, &admission);
178            LoginView::of(grant, standing)
179        })
180        .collect();
181    rows.sort_by(|left, right| {
182        (&left.client_id, &left.account, left.family).cmp(&(
183            &right.client_id,
184            &right.account,
185            right.family,
186        ))
187    });
188    Json(PanelState::Live { data: rows }).into_response()
189}
190
191/// `GET /dashboard/api/apps/refusals`
192///
193/// The evaluation log's own rows: policy denials at sign-in, at token
194/// issue and at the write, scope refusals of writes outside a token's
195/// scope, and ceiling narrowings at the pushed request.
196async fn refusals(
197    State(state): State<AppState>,
198    headers: axum::http::HeaderMap,
199    Query(filter): Query<AppFilter>,
200) -> Response {
201    if let Err(refusal) = signed_in(&state, &headers, false) {
202        return *refusal;
203    }
204    let Some(rows) = state.oauth.evaluation_log.rows() else {
205        return Json(PanelState::<Vec<DenialRow>>::Unreachable {
206            reason: "this deployment's evaluation log cannot be listed, so what it refused \
207                     is not readable here"
208                .to_owned(),
209        })
210        .into_response();
211    };
212    let since = match filter.since() {
213        Ok(since) => since,
214        Err(refused) => return *refused,
215    };
216    let account = filter.account(&state);
217    let rows: Vec<DenialRow> = rows
218        .into_iter()
219        .filter(|row| filter.admits(row.client_id.as_deref(), &row.account, account.as_deref()))
220        .filter(|row| since.is_none_or(|since| row.closed_at >= since))
221        .collect();
222    Json(PanelState::Live { data: rows }).into_response()
223}
224
225/// `GET /dashboard/api/apps/writes`
226///
227/// Every record a caller wrote or deleted, with the commit revision, the
228/// key, what the write did, the record's CID and the operator policy
229/// digest that admitted it.
230async fn writes(
231    State(state): State<AppState>,
232    headers: axum::http::HeaderMap,
233    Query(filter): Query<AppFilter>,
234) -> Response {
235    if let Err(refusal) = signed_in(&state, &headers, false) {
236        return *refusal;
237    }
238    let Some(rows) = state.registry.write_rows() else {
239        return Json(PanelState::<Vec<WriteRow>>::Unreachable {
240            reason: "this deployment's write log cannot be listed, so what an app wrote is \
241                     not readable here"
242                .to_owned(),
243        })
244        .into_response();
245    };
246    let since = match filter.since() {
247        Ok(since) => since,
248        Err(refused) => return *refused,
249    };
250    let account = filter.account(&state);
251    let rows: Vec<WriteRow> = rows
252        .into_iter()
253        .filter(|row| filter.admits(row.client_id.as_deref(), &row.account, account.as_deref()))
254        .filter(|row| since.is_none_or(|since| row.at >= since))
255        .collect();
256    Json(PanelState::Live { data: rows }).into_response()
257}
258
259/// What `POST /dashboard/api/apps/end-logins` takes.
260#[derive(Debug, serde::Deserialize)]
261pub struct EndLoginsBody {
262    /// The application, by `client_id`.
263    client: String,
264    /// One account, by handle or `did:`. Absent ends the application's
265    /// logins on every account this deployment holds.
266    #[serde(default)]
267    account: Option<String>,
268}
269
270/// `POST /dashboard/api/apps/end-logins`
271///
272/// Ends every grant family the client holds, and drops its unredeemed
273/// authorization codes and pending consents so nothing in flight becomes a
274/// token afterwards. The accounts' remembered approvals of the client are
275/// forgotten too, so its next sign-in is asked. A durable grant store writes the ending down before
276/// this answers. When it cannot, the logins still end in this process, and
277/// the answer is an error saying a restart may bring them back.
278///
279/// Each account a login ended on gains one `loginsEnded` entry naming the
280/// operator and the application, in DID order, and the answer lists them.
281/// An ending that reached no account writes no entry.
282///
283/// The account is the one the path's `account` names, resolved the way the
284/// `com.atproto.repo.*` routes resolve a repository, so an operator may
285/// type the handle they already know it by. An account this deployment
286/// does not hold is refused rather than quietly matching nothing.
287async fn end_logins(
288    State(state): State<AppState>,
289    headers: axum::http::HeaderMap,
290    Json(body): Json<EndLoginsBody>,
291) -> Response {
292    if let Err(refused) = signed_in(&state, &headers, true) {
293        return *refused;
294    }
295    let operator = match acting_operator(&state) {
296        Ok(operator) => operator,
297        Err(refused) => return *refused,
298    };
299    if body.client.trim().is_empty() {
300        return refusal(
301            StatusCode::BAD_REQUEST,
302            "NoClient",
303            "name the application whose logins to end, by its client_id",
304        );
305    }
306    let account = match body.account.as_deref() {
307        None => None,
308        Some(named) => match crate::routes::resolve_repo(&state, named) {
309            Ok(did) => Some(did),
310            Err(error) => return error.into_response(),
311        },
312    };
313    let ended = state
314        .oauth
315        .tokens
316        .revoke_client(&body.client, account.as_deref());
317    let codes = state
318        .oauth
319        .code_store
320        .revoke_client(&body.client, account.as_deref());
321    let consents = state
322        .oauth
323        .consent_store
324        .revoke_client(&body.client, account.as_deref());
325    let remembered = state
326        .oauth
327        .decision_store
328        .forget_client(&body.client, account.as_deref());
329    let ended = match ended {
330        Ok(ended) => ended,
331        Err(unkept) => {
332            tracing::error!(
333                operator,
334                client = body.client,
335                account = account.as_deref().unwrap_or("every account"),
336                codes,
337                consents,
338                remembered,
339                error = %unkept.reason,
340                "an operator ended an application's logins, and pds.grants could not be written"
341            );
342            return refusal(
343                StatusCode::INTERNAL_SERVER_ERROR,
344                "GrantStoreFailed",
345                format!(
346                    "the logins ended, and pds.grants could not be written: {}. They may \
347                     return after a restart.",
348                    unkept.reason
349                ),
350            );
351        }
352    };
353    let reached: std::collections::BTreeSet<&str> =
354        ended.iter().map(|login| login.did.as_str()).collect();
355    let mut accounts = Vec::with_capacity(reached.len());
356    for did in reached {
357        match state
358            .registry
359            .record_logins_ended(did, &body.client, &operator)
360        {
361            Ok(entry) => accounts.push(Acted {
362                did: did.to_owned(),
363                ledger: Some(entry),
364            }),
365            Err(error) => {
366                tracing::error!(
367                    operator,
368                    client = body.client,
369                    did,
370                    %error,
371                    "an operator ended an application's logins, and the account's ledger could \
372                     not record it"
373                );
374                return refused_partway(&error, &accounts);
375            }
376        }
377    }
378    tracing::info!(
379        operator,
380        client = body.client,
381        account = account.as_deref().unwrap_or("every account"),
382        families = ended.len(),
383        accounts = accounts.len(),
384        codes,
385        consents,
386        remembered,
387        "an operator ended an application's logins"
388    );
389    Json(serde_json::json!({
390        "action": "end-logins",
391        "client": body.client,
392        "account": account,
393        "operator": operator,
394        "families": ended.len(),
395        "codes": codes,
396        "consents": consents,
397        "accounts": accounts,
398    }))
399    .into_response()
400}