1use 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
48pub 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#[derive(Debug, Clone, Default, serde::Deserialize)]
59#[serde(default)]
60pub struct AppFilter {
61 client: Option<String>,
63 account: Option<String>,
65 since: Option<String>,
69}
70
71impl AppFilter {
72 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 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 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#[derive(Debug, Clone, Serialize)]
106#[serde(rename_all = "camelCase")]
107struct LoginView {
108 family: u64,
110 account: String,
112 client_id: String,
114 approved_scope: String,
116 scope_in_force: String,
118 admitted: bool,
120 dpop_thumbprint: String,
122 access_expires_at: String,
124 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
147async 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
191async 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
225async 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#[derive(Debug, serde::Deserialize)]
261pub struct EndLoginsBody {
262 client: String,
264 #[serde(default)]
267 account: Option<String>,
268}
269
270async 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}