1use 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
32pub const COLLAPSE_MINUTES: i32 = 5;
34
35pub const MAX_LIST: i64 = 1000;
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Channel {
42 Web,
44 Api,
46 Mcp,
48 Assistant,
50 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Kind {
93 View,
95 Download,
97 Package,
99 Export,
101 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#[derive(Clone, Copy, Debug)]
143pub struct AccessCtx {
144 pub channel: Channel,
145 pub client: Option<IpAddr>,
146}
147
148impl AccessCtx {
149 pub fn web() -> AccessCtx {
151 AccessCtx {
152 channel: Channel::Web,
153 client: None,
154 }
155 }
156
157 pub fn through(self, channel: Channel) -> AccessCtx {
160 AccessCtx { channel, ..self }
161 }
162}
163
164pub struct Subject<'a> {
166 pub kind: Kind,
167 pub project_id: Option<Uuid>,
168 pub object_type: &'a str,
170 pub object_id: Option<Uuid>,
171 pub label: &'a str,
173 pub detail: &'a str,
175}
176
177pub 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
218pub 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 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 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
282pub 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
307pub 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
346pub 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#[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 pub detail: String,
375}
376
377#[derive(Clone, Debug, Default)]
379pub struct Filter {
380 pub username: Option<String>,
381 pub kind: Option<Kind>,
382 pub channel: Option<Channel>,
383 pub object: Option<String>,
386 pub since: Option<DateTime<Utc>>,
387 pub until: Option<DateTime<Utc>>,
388 pub limit: Option<i64>,
389 pub before: Option<Uuid>,
392}
393
394#[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
413fn 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 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#[derive(Clone, Debug, Serialize)]
474pub struct Page {
475 pub entries: Vec<Entry>,
476 pub next: Option<Uuid>,
479}
480
481pub 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
511pub 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 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 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
575pub 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
584pub 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 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 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}