Skip to main content

teamcraft/services/
access_log.rs

1//! Read and download log of restricted groups (#151, step 4).
2//!
3//! Only groups whose need-to-know policy has `read_log` write here, so the
4//! volume stays bounded and nothing is logged for other groups. The log is
5//! separate from the audit log, which records changes. An entry says who
6//! viewed or downloaded what, through which channel, from which address.
7//!
8//! Repeated reads of the same object by the same user through the same
9//! channel within [`COLLAPSE_MINUTES`] are one entry, so a resumed or ranged
10//! download (many requests) is logged once.
11//!
12//! What is logged: item and revision pages (and the same data over the REST
13//! API), file downloads, Pack and Go, BOM exports, the desktop app's Get
14//! ([`Kind::Workset`]) and the read tools of MCP clients and the built-in
15//! assistant that name an item ([`record_tool_read`]). Lists and searches
16//! are not logged, in no channel.
17
18use std::net::IpAddr;
19
20use chrono::{DateTime, Utc};
21use serde::Serialize;
22use serde_json::json;
23use sqlx::PgPool;
24use uuid::Uuid;
25
26use crate::{
27    auth::Actor,
28    error::{AppError, AppResult},
29    services::{audit, projects},
30};
31
32/// Reads of one object within this window are logged once.
33pub const COLLAPSE_MINUTES: i32 = 5;
34
35/// Most entries one listing returns.
36pub const MAX_LIST: i64 = 1000;
37
38/// The way a request came in.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Channel {
42    /// Browser session.
43    Web,
44    /// Bearer token: REST API, desktop app.
45    Api,
46    /// A tool called by an MCP client.
47    Mcp,
48    /// A tool called by the built-in assistant.
49    Assistant,
50    /// The desktop app's Get.
51    Desktop,
52}
53
54impl Channel {
55    pub const ALL: [Channel; 5] = [
56        Channel::Web,
57        Channel::Api,
58        Channel::Mcp,
59        Channel::Assistant,
60        Channel::Desktop,
61    ];
62
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Channel::Web => "web",
66            Channel::Api => "api",
67            Channel::Mcp => "mcp",
68            Channel::Assistant => "assistant",
69            Channel::Desktop => "desktop",
70        }
71    }
72
73    pub fn parse(raw: &str) -> Option<Channel> {
74        let raw = raw.trim().to_ascii_lowercase();
75        Channel::ALL.into_iter().find(|c| c.as_str() == raw)
76    }
77
78    pub fn label(self) -> &'static str {
79        match self {
80            Channel::Web => "Browser",
81            Channel::Api => "REST API",
82            Channel::Mcp => "MCP client",
83            Channel::Assistant => "Assistant",
84            Channel::Desktop => "Desktop app",
85        }
86    }
87}
88
89/// What was done with the object.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Kind {
93    /// An item or revision page (or the same data over the REST API).
94    View,
95    /// A file or file version.
96    Download,
97    /// Pack and Go.
98    Package,
99    /// A BOM export (CSV, Excel).
100    Export,
101    /// The working set of the desktop app's Get.
102    Workset,
103}
104
105impl Kind {
106    pub const ALL: [Kind; 5] = [
107        Kind::View,
108        Kind::Download,
109        Kind::Package,
110        Kind::Export,
111        Kind::Workset,
112    ];
113
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Kind::View => "view",
117            Kind::Download => "download",
118            Kind::Package => "package",
119            Kind::Export => "export",
120            Kind::Workset => "workset",
121        }
122    }
123
124    pub fn parse(raw: &str) -> Option<Kind> {
125        let raw = raw.trim().to_ascii_lowercase();
126        Kind::ALL.into_iter().find(|k| k.as_str() == raw)
127    }
128
129    pub fn label(self) -> &'static str {
130        match self {
131            Kind::View => "View",
132            Kind::Download => "Download",
133            Kind::Package => "Pack and Go",
134            Kind::Export => "BOM export",
135            Kind::Workset => "Desktop Get",
136        }
137    }
138}
139
140/// Channel and client address of the request, set by the extractor
141/// `auth::AccessCtx`.
142#[derive(Clone, Copy, Debug)]
143pub struct AccessCtx {
144    pub channel: Channel,
145    pub client: Option<IpAddr>,
146}
147
148impl AccessCtx {
149    /// For tests and background callers.
150    pub fn web() -> AccessCtx {
151        AccessCtx {
152            channel: Channel::Web,
153            client: None,
154        }
155    }
156
157    /// The same request seen through another channel (an MCP call arrives
158    /// as an API request, a Get as a desktop one).
159    pub fn through(self, channel: Channel) -> AccessCtx {
160        AccessCtx { channel, ..self }
161    }
162}
163
164/// An object that was read.
165pub struct Subject<'a> {
166    pub kind: Kind,
167    pub project_id: Option<Uuid>,
168    /// `item`, `revision`, `file`.
169    pub object_type: &'a str,
170    pub object_id: Option<Uuid>,
171    /// Item number, revision label or file name: kept as text.
172    pub label: &'a str,
173    /// What read it (a tool name); empty for pages and downloads.
174    pub detail: &'a str,
175}
176
177/// Logs a read when the object's group logs reads. Without a group, or when
178/// the group does not log, nothing is written. A failing write is an error:
179/// a download of a logged group that cannot be logged does not happen.
180pub async fn record(
181    pool: &PgPool,
182    ctx: &AccessCtx,
183    actor: &Actor,
184    s: Subject<'_>,
185) -> AppResult<()> {
186    let Some(project_id) = s.project_id else {
187        return Ok(());
188    };
189    sqlx::query(
190        "INSERT INTO access_log (project_id, user_id, username, channel, kind, object_type,
191                                 object_id, label, client_address, detail)
192         SELECT $1::uuid, $2::uuid, $3::text, $4::text, $5::text, $6::text, $7::uuid, $8::text,
193                $9::text, $11::text
194         WHERE EXISTS (SELECT 1 FROM project_policies pp
195                       JOIN projects p ON p.id = pp.project_id
196                       WHERE pp.project_id = $1 AND pp.read_log AND p.visibility = 'restricted')
197           AND NOT EXISTS (SELECT 1 FROM access_log l
198                           WHERE l.user_id = $2 AND l.kind = $5 AND l.channel = $4
199                             AND l.object_id IS NOT DISTINCT FROM $7
200                             AND l.at > now() - ($10::int * interval '1 minute'))",
201    )
202    .bind(project_id)
203    .bind(actor.id)
204    .bind(&actor.username)
205    .bind(ctx.channel.as_str())
206    .bind(s.kind.as_str())
207    .bind(s.object_type)
208    .bind(s.object_id)
209    .bind(s.label.chars().take(300).collect::<String>())
210    .bind(ctx.client.map(|ip| ip.to_string()))
211    .bind(COLLAPSE_MINUTES)
212    .bind(s.detail.chars().take(300).collect::<String>())
213    .execute(pool)
214    .await?;
215    Ok(())
216}
217
218/// Logs a read tool of an MCP client or the built-in assistant that named an
219/// item (`item`, as `PRT-000012` or `PRT-000012/B`, plus an optional
220/// `revision`): one view of the item per tool, labelled with what the tool
221/// was asked for. Write tools, tools without an item (searches, lists,
222/// change orders) and items the actor may not see log nothing. The tool name
223/// is the entry's detail (`read_file_text` adds the file).
224pub async fn record_tool_read(
225    pool: &PgPool,
226    ctx: &AccessCtx,
227    actor: &Actor,
228    tool: &str,
229    args: &serde_json::Value,
230) -> AppResult<()> {
231    let Some(raw) = args.get("item").and_then(serde_json::Value::as_str) else {
232        return Ok(());
233    };
234    // `PRT-000012/B`: item numbers never contain a slash
235    let (number, code) = match raw.trim().split_once('/') {
236        Some((n, c)) => (
237            n.trim(),
238            Some(c.trim().to_string()).filter(|c| !c.is_empty()),
239        ),
240        None => (raw.trim(), None),
241    };
242    if number.is_empty() {
243        return Ok(());
244    }
245    let item = match crate::services::items::get_by_number(pool, actor, number).await {
246        Ok(item) => item,
247        // the tool answered (an error names nothing of a hidden item)
248        Err(AppError::NotFound(_)) => return Ok(()),
249        Err(e) => return Err(e),
250    };
251    let code = code.or_else(|| {
252        ["revision", "to"]
253            .iter()
254            .find_map(|k| args.get(*k).and_then(serde_json::Value::as_str))
255            .map(|c| c.trim().to_string())
256            .filter(|c| !c.is_empty())
257    });
258    let label = match &code {
259        Some(c) => format!("{}/{c}", item.number),
260        None => item.number.clone(),
261    };
262    let detail = match args.get("file").and_then(serde_json::Value::as_str) {
263        Some(file) if tool == "read_file_text" => format!("{tool}: {file}"),
264        _ => tool.to_string(),
265    };
266    record(
267        pool,
268        ctx,
269        actor,
270        Subject {
271            kind: Kind::View,
272            project_id: item.project_id,
273            object_type: "item",
274            object_id: Some(item.id),
275            label: &label,
276            detail: &detail,
277        },
278    )
279    .await
280}
281
282/// Logs a read of a revision (its page, package or BOM export).
283pub async fn record_revision(
284    pool: &PgPool,
285    ctx: &AccessCtx,
286    actor: &Actor,
287    rev: &crate::models::Revision,
288    kind: Kind,
289) -> AppResult<()> {
290    let label = format!("{}/{}", rev.item_number, rev.code);
291    record(
292        pool,
293        ctx,
294        actor,
295        Subject {
296            kind,
297            project_id: rev.project_id,
298            object_type: "revision",
299            object_id: Some(rev.id),
300            label: &label,
301            detail: "",
302        },
303    )
304    .await
305}
306
307/// Logs the read of a whole structure (Pack and Go, BOM export): one entry per
308/// group of the structure that logs, labelled with the first revision of that
309/// group. `revision_ids` holds the root and every revision of the tree.
310pub async fn record_structure(
311    pool: &PgPool,
312    ctx: &AccessCtx,
313    actor: &Actor,
314    kind: Kind,
315    revision_ids: &[Uuid],
316) -> AppResult<()> {
317    let rows: Vec<(Uuid, Uuid, String, String)> = sqlx::query_as(
318        "SELECT DISTINCT ON (i.project_id) r.id, i.project_id, i.number, r.code
319         FROM revisions r JOIN items i ON i.id = r.item_id
320         WHERE r.id = ANY($1) AND i.project_id IS NOT NULL
321         ORDER BY i.project_id, array_position($1, r.id)",
322    )
323    .bind(revision_ids)
324    .fetch_all(pool)
325    .await?;
326    for (id, project_id, number, code) in rows {
327        let label = format!("{number}/{code}");
328        record(
329            pool,
330            ctx,
331            actor,
332            Subject {
333                kind,
334                project_id: Some(project_id),
335                object_type: "revision",
336                object_id: Some(id),
337                label: &label,
338                detail: "",
339            },
340        )
341        .await?;
342    }
343    Ok(())
344}
345
346/// The group of a file (through its revision and item).
347pub async fn project_of_file(pool: &PgPool, file_id: Uuid) -> AppResult<Option<Uuid>> {
348    Ok(sqlx::query_scalar::<_, Option<Uuid>>(
349        "SELECT i.project_id FROM files f
350         JOIN revisions r ON r.id = f.revision_id
351         JOIN items i ON i.id = r.item_id
352         WHERE f.id = $1",
353    )
354    .bind(file_id)
355    .fetch_optional(pool)
356    .await?
357    .flatten())
358}
359
360/// One entry of the log.
361#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
362pub struct Entry {
363    pub id: Uuid,
364    pub at: DateTime<Utc>,
365    pub user_id: Option<Uuid>,
366    pub username: String,
367    pub channel: String,
368    pub kind: String,
369    pub object_type: String,
370    pub object_id: Option<Uuid>,
371    pub label: String,
372    pub client_address: Option<String>,
373    /// What read it (a tool name); empty for pages and downloads.
374    pub detail: String,
375}
376
377/// Filter of [`list`].
378#[derive(Clone, Debug, Default)]
379pub struct Filter {
380    pub username: Option<String>,
381    pub kind: Option<Kind>,
382    pub channel: Option<Channel>,
383    /// Part of the label (item number, revision, file name) or the detail,
384    /// case-insensitive.
385    pub object: Option<String>,
386    pub since: Option<DateTime<Utc>>,
387    pub until: Option<DateTime<Utc>>,
388    pub limit: Option<i64>,
389    /// Paging: only entries after (older than) this one, in the order of
390    /// the list.
391    pub before: Option<Uuid>,
392}
393
394/// The filter as a query string sends it (REST API and the log page): every
395/// field optional, empty = not set. Times are RFC 3339 or a date
396/// (`YYYY-MM-DD`, UTC); a date `until` includes that whole day.
397#[derive(Clone, Debug, Default, serde::Deserialize, Serialize)]
398pub struct FilterQuery {
399    pub user: Option<String>,
400    pub kind: Option<String>,
401    pub channel: Option<String>,
402    pub object: Option<String>,
403    pub since: Option<String>,
404    pub until: Option<String>,
405    pub limit: Option<i64>,
406    pub before: Option<Uuid>,
407}
408
409fn present(v: &Option<String>) -> Option<&str> {
410    v.as_deref().map(str::trim).filter(|s| !s.is_empty())
411}
412
413/// A time of a filter; `end` moves a bare date to the start of the next day.
414fn filter_time(field: &str, raw: &str, end: bool) -> AppResult<DateTime<Utc>> {
415    if let Ok(t) = DateTime::parse_from_rfc3339(raw) {
416        return Ok(t.with_timezone(&Utc));
417    }
418    let day = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|_| {
419        AppError::BadRequest(format!(
420            "{field} must be a date (YYYY-MM-DD) or an RFC 3339 time."
421        ))
422    })?;
423    let day = if end {
424        day.succ_opt().unwrap_or(day)
425    } else {
426        day
427    };
428    Ok(day.and_time(chrono::NaiveTime::MIN).and_utc())
429}
430
431impl FilterQuery {
432    /// The filter; an unknown kind or channel or a bad time is a 400.
433    pub fn parse(&self) -> AppResult<Filter> {
434        let names = |all: Vec<&str>| all.join(", ");
435        let kind = present(&self.kind)
436            .map(|raw| {
437                Kind::parse(raw).ok_or_else(|| {
438                    AppError::BadRequest(format!(
439                        "kind must be one of {}.",
440                        names(Kind::ALL.iter().map(|k| k.as_str()).collect())
441                    ))
442                })
443            })
444            .transpose()?;
445        let channel = present(&self.channel)
446            .map(|raw| {
447                Channel::parse(raw).ok_or_else(|| {
448                    AppError::BadRequest(format!(
449                        "channel must be one of {}.",
450                        names(Channel::ALL.iter().map(|c| c.as_str()).collect())
451                    ))
452                })
453            })
454            .transpose()?;
455        Ok(Filter {
456            username: present(&self.user).map(str::to_string),
457            kind,
458            channel,
459            object: present(&self.object).map(str::to_string),
460            since: present(&self.since)
461                .map(|t| filter_time("since", t, false))
462                .transpose()?,
463            until: present(&self.until)
464                .map(|t| filter_time("until", t, true))
465                .transpose()?,
466            limit: self.limit,
467            before: self.before,
468        })
469    }
470}
471
472/// One page of the log.
473#[derive(Clone, Debug, Serialize)]
474pub struct Page {
475    pub entries: Vec<Entry>,
476    /// The id to pass as [`Filter::before`] for the next (older) page;
477    /// `None` on the last page.
478    pub next: Option<Uuid>,
479}
480
481/// Like [`list`], with the cursor of the next page.
482pub async fn page(
483    pool: &PgPool,
484    actor: &Actor,
485    project_id: Uuid,
486    filter: &Filter,
487) -> AppResult<Page> {
488    let limit = filter.limit.unwrap_or(200).clamp(1, MAX_LIST);
489    let mut entries = list(
490        pool,
491        actor,
492        project_id,
493        &Filter {
494            limit: Some(limit + 1),
495            ..filter.clone()
496        },
497    )
498    .await?;
499    let more = entries.len() as i64 > limit;
500    entries.truncate(limit as usize);
501    Ok(Page {
502        next: if more {
503            entries.last().map(|e| e.id)
504        } else {
505            None
506        },
507        entries,
508    })
509}
510
511/// The log of a group, newest first (managers of the group and
512/// administrators; a group the actor may not know answers "not found").
513pub async fn list(
514    pool: &PgPool,
515    actor: &Actor,
516    project_id: Uuid,
517    filter: &Filter,
518) -> AppResult<Vec<Entry>> {
519    projects::get_for_management(pool, actor, project_id).await?;
520    // Managing the group is not reading it: an administrator shut out by the
521    // need-to-know policy sees neither items nor the labels in the log.
522    if !actor.can_see_project(Some(project_id)) {
523        return Err(AppError::not_found("Project not found."));
524    }
525    let mut conn = pool.acquire().await?;
526    if !projects::can_manage(&mut conn, actor, project_id).await? {
527        return Err(AppError::Forbidden(
528            "Only the managers of this project group (and administrators) can read its access log."
529                .into(),
530        ));
531    }
532    // the cursor is an entry of this group; another id ends the list
533    Ok(sqlx::query_as::<_, Entry>(
534        "SELECT id, at, user_id, username, channel, kind, object_type, object_id, label,
535                client_address, detail
536         FROM access_log l
537         WHERE project_id = $1
538           AND ($2::text IS NULL OR lower(username) = lower($2))
539           AND ($3::text IS NULL OR kind = $3)
540           AND ($4::timestamptz IS NULL OR at >= $4)
541           AND ($5::timestamptz IS NULL OR at < $5)
542           AND ($7::text IS NULL OR channel = $7)
543           AND ($8::text IS NULL OR strpos(lower(label), lower($8)) > 0
544                OR strpos(lower(detail), lower($8)) > 0)
545           AND ($9::uuid IS NULL OR (at, id) < (SELECT c.at, c.id FROM access_log c
546                                                WHERE c.id = $9 AND c.project_id = $1))
547         ORDER BY at DESC, id DESC
548         LIMIT $6",
549    )
550    .bind(project_id)
551    .bind(
552        filter
553            .username
554            .as_deref()
555            .map(str::trim)
556            .filter(|s| !s.is_empty()),
557    )
558    .bind(filter.kind.map(Kind::as_str))
559    .bind(filter.since)
560    .bind(filter.until)
561    .bind(filter.limit.unwrap_or(200).clamp(1, MAX_LIST))
562    .bind(filter.channel.map(Channel::as_str))
563    .bind(
564        filter
565            .object
566            .as_deref()
567            .map(str::trim)
568            .filter(|s| !s.is_empty()),
569    )
570    .bind(filter.before)
571    .fetch_all(&mut *conn)
572    .await?)
573}
574
575/// The id of a group by its code, for the CLI (no actor).
576pub async fn project_id_by_code(pool: &PgPool, code: &str) -> AppResult<Uuid> {
577    sqlx::query_scalar::<_, Uuid>("SELECT id FROM projects WHERE upper(code) = upper($1)")
578        .bind(code.trim())
579        .fetch_optional(pool)
580        .await?
581        .ok_or_else(|| AppError::not_found("Project not found."))
582}
583
584/// Deletes old entries (the `teamcraft access-log purge` command; no actor,
585/// the operator of the server).
586///
587/// With `older_than_days` every entry older than that goes (of one group, or
588/// of all); without it each group's own `log_retention_days` applies and
589/// groups without one keep their log.
590///
591/// # Invariants
592///
593/// * **Permission:** none here; only the CLI calls it.
594/// * **Audit:** `access_log.purge` with the count, in the same transaction.
595/// * **Atomic:** one transaction.
596pub async fn purge(
597    pool: &PgPool,
598    project_id: Option<Uuid>,
599    older_than_days: Option<i32>,
600) -> AppResult<u64> {
601    if older_than_days.is_some_and(|d| d < 0) {
602        return Err(AppError::BadRequest(
603            "--older-than-days must not be negative.".into(),
604        ));
605    }
606    let mut tx = pool.begin().await?;
607    let deleted = match older_than_days {
608        Some(days) => {
609            sqlx::query(
610                "DELETE FROM access_log
611                 WHERE at < now() - ($1::int * interval '1 day')
612                   AND ($2::uuid IS NULL OR project_id = $2)",
613            )
614            .bind(days)
615            .bind(project_id)
616            .execute(&mut *tx)
617            .await?
618        }
619        None => {
620            sqlx::query(
621                "DELETE FROM access_log l USING project_policies pp
622                 WHERE pp.project_id = l.project_id AND pp.log_retention_days IS NOT NULL
623                   AND l.at < now() - (pp.log_retention_days * interval '1 day')
624                   AND ($1::uuid IS NULL OR l.project_id = $1)",
625            )
626            .bind(project_id)
627            .execute(&mut *tx)
628            .await?
629        }
630    }
631    .rows_affected();
632    audit::record(
633        &mut tx,
634        None,
635        "access_log.purge",
636        "project",
637        project_id,
638        format!("Access log purged: {deleted} entries"),
639        json!({ "deleted": deleted, "older_than_days": older_than_days,
640                "project_id": project_id }),
641    )
642    .await?;
643    tx.commit().await?;
644    Ok(deleted)
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn kinds_and_channels_have_stable_names() {
653        for k in Kind::ALL {
654            assert_eq!(Kind::parse(k.as_str()), Some(k));
655            assert!(!k.label().is_empty());
656        }
657        assert_eq!(Kind::parse(" Workset "), Some(Kind::Workset));
658        assert_eq!(Kind::parse("delete"), None);
659        for c in Channel::ALL {
660            assert_eq!(Channel::parse(c.as_str()), Some(c));
661            assert!(!c.label().is_empty());
662        }
663        assert_eq!(Channel::Web.as_str(), "web");
664        assert_eq!(Channel::Api.as_str(), "api");
665        assert_eq!(Channel::parse("MCP"), Some(Channel::Mcp));
666        assert_eq!(Channel::parse("email"), None);
667    }
668
669    #[test]
670    fn query_strings_become_filters() {
671        let q = FilterQuery {
672            user: Some("  ".into()),
673            kind: Some("Download".into()),
674            channel: Some("assistant".into()),
675            object: Some(" PRT-1 ".into()),
676            since: Some("2026-11-01".into()),
677            until: Some("2026-11-02".into()),
678            limit: Some(20),
679            before: None,
680        };
681        let f = q.parse().unwrap();
682        assert_eq!(f.username, None, "blank is not set");
683        assert_eq!(f.kind, Some(Kind::Download));
684        assert_eq!(f.channel, Some(Channel::Assistant));
685        assert_eq!(f.object.as_deref(), Some("PRT-1"));
686        assert_eq!(f.since.unwrap().to_rfc3339(), "2026-11-01T00:00:00+00:00");
687        // a date `until` includes the whole day
688        assert_eq!(f.until.unwrap().to_rfc3339(), "2026-11-03T00:00:00+00:00");
689        let exact = FilterQuery {
690            until: Some("2026-11-02T10:30:00Z".into()),
691            ..Default::default()
692        };
693        assert_eq!(
694            exact.parse().unwrap().until.unwrap().to_rfc3339(),
695            "2026-11-02T10:30:00+00:00"
696        );
697        for bad in [
698            FilterQuery {
699                kind: Some("delete".into()),
700                ..Default::default()
701            },
702            FilterQuery {
703                channel: Some("email".into()),
704                ..Default::default()
705            },
706            FilterQuery {
707                since: Some("yesterday".into()),
708                ..Default::default()
709            },
710        ] {
711            assert!(matches!(bad.parse(), Err(AppError::BadRequest(_))));
712        }
713        assert!(FilterQuery::default().parse().unwrap().kind.is_none());
714    }
715
716    #[test]
717    fn the_names_match_the_check_constraints() {
718        // migrations/0066: the CHECK constraints list exactly these
719        let sql = include_str!("../../migrations/0066_access_log_tools_and_desktop.sql");
720        let channels: Vec<_> = Channel::ALL
721            .iter()
722            .map(|c| format!("'{}'", c.as_str()))
723            .collect();
724        assert!(sql.contains(&format!("channel IN ({})", channels.join(", "))));
725        let kinds: Vec<_> = Kind::ALL
726            .iter()
727            .map(|k| format!("'{}'", k.as_str()))
728            .collect();
729        assert!(sql.contains(&format!("kind IN ({})", kinds.join(", "))));
730    }
731}