diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index 6e360fc24..ee26e0adb 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -65,7 +65,7 @@ cfg-if = "1.0" url = { version = "2.5", features = ["serde"] } ulid = { version = "1", features = ["serde"] } uuid = { version = "1.23", features = ["v4", "serde"] } -time = { version = "0.3", default-features = false, features = ["std", "serde", "formatting"] } +time = { version = "0.3", default-features = false, features = ["std", "serde", "formatting", "parsing"] } parking_lot = "0.12" anyhow = "1.0" thiserror = "2" diff --git a/devolutions-gateway/src/api/jrec.rs b/devolutions-gateway/src/api/jrec/mod.rs similarity index 99% rename from devolutions-gateway/src/api/jrec.rs rename to devolutions-gateway/src/api/jrec/mod.rs index 95f2ce710..32f4b7d14 100644 --- a/devolutions-gateway/src/api/jrec.rs +++ b/devolutions-gateway/src/api/jrec/mod.rs @@ -1,3 +1,5 @@ +pub(crate) mod search; + use std::fs; use std::io::{self, Seek as _, Write as _}; use std::net::SocketAddr; @@ -12,7 +14,7 @@ use axum::extract::ws::WebSocket; use axum::extract::{self, ConnectInfo, Query, State, WebSocketUpgrade}; use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue}; use axum::response::Response; -use axum::routing::{delete, get}; +use axum::routing::{delete, get, post}; use axum::{Json, Router}; use bytes::Bytes; use cadeau::xmf; @@ -59,6 +61,7 @@ pub fn make_router(state: DgwState) -> Router { .route("/play", get(get_player)) .route("/play/{*path}", get(get_player)) .route("/shadow/{id}", get(shadow_recording)) + .route("/search", post(search::search_recordings)) .with_state(state) } diff --git a/devolutions-gateway/src/api/jrec/search.intent.md b/devolutions-gateway/src/api/jrec/search.intent.md new file mode 100644 index 000000000..864975d46 --- /dev/null +++ b/devolutions-gateway/src/api/jrec/search.intent.md @@ -0,0 +1,42 @@ +## What is searched + +Search reads only the files listed under `artifacts` in `recording.json` whose type is `slog`. +Recording files listed under `files` are not searched, a `.slog` recording included. + +A line holding a JSON object is matched case-insensitively against the object's string values, at any depth and once unescaped; field names, numbers, booleans and null never match. +Any other line is matched case-insensitively against its raw text, so that any kind of artifact, present or future, can be searched. +A line cut at 256 KiB follows the same rule: what was read is matched by its string values when it parses as a JSON object, and as raw text otherwise. + +Values are matched rather than raw text because users search the text they see: raw matching would make a field name such as `description` match every line, and would miss text that JSON escapes, such as `DOMAIN\user` stored as `DOMAIN\\user`. +The rule never depends on field names, so a producer can change the fields it writes without any change to search. +Per-field search, such as matching an entry's `description` only, is not supported: the fields a producer writes are unknown to Gateway, which only cares about values. + +## Hits + +A hit is a line that matches the query. +A hit carries the recording ID, the file name, the line number counting from 1, the byte offset of the line, and the raw line itself as a string. +Line numbers count from 1 like the slog package in `webapp/packages/session-recording-log`, so that a hit and that package's own search give a line the same number. +Gateway does not require a line to be JSON: a `.slog` is NDJSON by format, but a line holds whatever its producer pushed, such as a half-written last line while the push is live. +A line longer than 256 KiB is matched on its first 256 KiB and returned cut, with `truncated: true`; the caller fetches the file for the whole line. + +## Access + +A valid token with the `gateway.recordings.search` scope is the only check, and the provisioner that issues the token decides who gets one. +Search has its own scope because `gateway.recordings.read` only lists recording IDs and must not reveal log contents. + +## Request + +`recordings` is `"all"` or a list of recording IDs, and stays open to new forms added without breaking existing callers. +`"all"` orders recordings newest first by start time, then by ID, so that the order is the same on every request; a list keeps its order and searches a repeated ID once. + +The optional `startTime` range takes RFC 3339 date-times and keeps a recording when `from <= startTime < to`, using the start time in `recording.json`, never anything inside a log. +A missing end leaves that side of the range open. + +## Limits + +One search reads about 16 MiB of logs at most and returns at most 1,000 hits, so that searching every recording takes a bounded time. + +## Later + +Regular expressions or wildcards, a total hit count, and combining words are wanted later, and the request shape must not preclude them. +Choosing which artifact kinds to search, or searching other sources such as the `.slog` recordings under `files`, is wanted later; the request shape must not preclude it either. diff --git a/devolutions-gateway/src/api/jrec/search.rs b/devolutions-gateway/src/api/jrec/search.rs new file mode 100644 index 000000000..ea7609801 --- /dev/null +++ b/devolutions-gateway/src/api/jrec/search.rs @@ -0,0 +1,898 @@ +use std::collections::HashSet; +use std::io; + +use axum::Json; +use axum::extract::State; +use camino::{Utf8Path, Utf8PathBuf}; +use serde_json::Value; +use time::OffsetDateTime; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use uuid::Uuid; + +use super::is_safe_recording_file_name; +use crate::DgwState; +use crate::artifacts::JrecArtifacts; +use crate::extract::RecordingsSearchScope; +use crate::http::HttpError; +use crate::token::RecordingFileType; + +/// Most hits returned by one search. +const MAX_HITS: usize = 1_000; + +/// Log bytes one search reads before it stops, so that searching every recording takes a bounded time. +/// +/// A search reads past it by about [`MAX_LINE_BYTES`] at most, to finish reading the start of a line. +const MAX_SCANNED_BYTES: u64 = 16 * 1024 * 1024; + +/// Bytes of a line that are matched and returned; the rest of a longer line is skipped. +const MAX_LINE_BYTES: usize = 256 * 1024; + +/// What to search for, and in which recordings. +/// +/// Unknown fields are rejected, so that an option added later is never silently ignored by a Gateway that predates it. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RecordingSearchRequest { + /// Text to search for, ignoring case; leading and trailing spaces are part of it. + #[cfg_attr(feature = "openapi", schema(min_length = 1))] + query: String, + /// The recordings to search. + recordings: RecordingSelection, + /// Only search the recordings that started in this range. + #[serde(default)] + start_time: Option, +} + +/// `"all"`, or a list of recording IDs. +/// +/// A repeated ID is searched once, and an empty list searches nothing. +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum RecordingSelection { + All(AllRecordings), + Listed(Vec), +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum AllRecordings { + All, +} + +// The derived schema puts `"all"` in its own enum schema, which version 7.9.0 of the C# client generator turns into +// a `oneOf` constructor that does not compile. +#[cfg(feature = "openapi")] +impl<'__s> utoipa::ToSchema<'__s> for RecordingSelection { + fn schema() -> (&'__s str, utoipa::openapi::RefOr) { + use utoipa::openapi::schema::{ + ArrayBuilder, KnownFormat, ObjectBuilder, OneOfBuilder, SchemaFormat, SchemaType, + }; + + let all = ObjectBuilder::new() + .schema_type(SchemaType::String) + .enum_values(Some(["all"])); + + let listed = ArrayBuilder::new().items( + ObjectBuilder::new() + .schema_type(SchemaType::String) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid))), + ); + + let schema = OneOfBuilder::new() + .item(all) + .item(listed) + .description(Some( + "`\"all\"`, or a list of recording IDs.\n\nA repeated ID is searched once, and an empty list searches nothing.", + )); + + ("RecordingSelection", schema.into()) + } +} + +/// A range of recording start times, as written in `recording.json`, that includes `from` and excludes `to`. +/// +/// A missing end leaves that side of the range open. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StartTimeRange { + /// Earliest start time searched. + #[serde(default, with = "time::serde::rfc3339::option")] + from: Option, + /// Recordings that start at this time or later are not searched. + #[serde(default, with = "time::serde::rfc3339::option")] + to: Option, +} + +impl StartTimeRange { + /// Whether a recording that started at `start_time`, in Unix seconds, is in the range. + fn contains(&self, start_time: i64) -> bool { + let start_time = i128::from(start_time) * 1_000_000_000; + + self.from.is_none_or(|from| from.unix_timestamp_nanos() <= start_time) + && self.to.is_none_or(|to| start_time < to.unix_timestamp_nanos()) + } +} + +/// The lines that match the search text. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecordingSearchResponse { + /// Matching lines, newest recording first when searching all recordings, otherwise in the requested order. + hits: Vec, + /// The search stopped before reading every log, so more lines may match. + limit_reached: bool, +} + +/// A line that matches the search text. +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecordingSearchHit { + /// The recording whose artifact holds the line. + recording_id: Uuid, + /// Name of the `.slog` artifact holding the line, as listed in `recording.json`. + file_name: String, + /// Number of the line in the file, counting from 1. + #[cfg_attr(feature = "openapi", schema(minimum = 1))] + line_number: u64, + /// Byte offset of the line in the file. + offset: u64, + /// The line's text without its line break; bytes that are not UTF-8 read as U+FFFD. + line: String, + /// The line is longer than 256 KiB, so `line` holds only its start, and the file holds the rest. + truncated: bool, +} + +/// Searches the `.slog` artifacts, such as AI analysis logs, of recordings stored on this instance +/// +/// The query is a case-insensitive substring. +/// In a line holding a JSON object, it is matched against the string values of the object at any depth, as they read +/// once unescaped, so field names, numbers, booleans and null never match. +/// Any other line, such as one still being written, is matched as raw text. +/// A line longer than 256 KiB is matched on its first 256 KiB only, by the same rule, so a cut JSON object is usually +/// matched as raw text. +/// Only the files listed under `artifacts` in `recording.json` with the `slog` type are searched. +/// Recordings without a readable `recording.json` and IDs with no recording on this instance give no hits. +#[cfg_attr(feature = "openapi", utoipa::path( + post, + operation_id = "SearchRecordings", + tag = "Jrec", + path = "/jet/jrec/search", + request_body(content = RecordingSearchRequest, description = "JSON-encoded search request", content_type = "application/json"), + responses( + (status = 200, description = "Matching log lines", body = RecordingSearchResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + ), + security(("scope_token" = ["gateway.recordings.search"])), +))] +pub(crate) async fn search_recordings( + State(DgwState { conf_handle, .. }): State, + _scope: RecordingsSearchScope, + Json(request): Json, +) -> Result, HttpError> { + let text = SearchText::new(&request.query).ok_or_else(|| HttpError::bad_request().msg("empty search text"))?; + + let recording_root = conf_handle.get_conf().recording_path.clone(); + + let mut recordings = match request.recordings { + RecordingSelection::All(AllRecordings::All) => all_recording_logs_newest_first(&recording_root) + .await + .map_err(HttpError::internal().with_msg("failed recording listing").err())?, + RecordingSelection::Listed(recording_ids) => { + let mut seen = HashSet::new(); + let mut recordings = Vec::with_capacity(recording_ids.len()); + for recording_id in recording_ids + .into_iter() + .filter(|recording_id| seen.insert(*recording_id)) + { + let recording_dir = recording_root.join(recording_id.to_string()); + recordings.extend(read_recording_logs(recording_dir, recording_id).await); + } + recordings + } + }; + + if let Some(start_time) = &request.start_time { + recordings.retain(|recording| start_time.contains(recording.start_time)); + } + + let mut search = Search::new(text); + + for recording in recordings { + for file_name in &recording.file_names { + let path = recording.dir.join(file_name); + + match search.search_file(recording.recording_id, file_name, &path).await { + Ok(()) => {} + // A recording can be deleted while it is searched. + Err(error) if error.kind() == io::ErrorKind::NotFound => { + trace!(%path, "Recording log listed in the manifest is missing"); + } + Err(error) => warn!(%path, %error, "Failed to search a recording log"), + } + + if search.limit_reached { + return Ok(Json(search.into_response())); + } + } + } + + Ok(Json(search.into_response())) +} + +/// The query, lowercased the same way as the text it is compared with. +struct SearchText(String); + +impl SearchText { + fn new(query: &str) -> Option { + (!query.is_empty()).then(|| Self(lowercase_for_matching(query))) + } + + /// Whether `line` matches the search text. + /// + /// In a JSON object, only the string values count, once unescaped, so that a field name never matches and + /// `DOMAIN\user` matches the `"DOMAIN\\user"` a JSON writer stores. + /// Any other line is compared as raw text, so that a line of plain text that happens to parse as JSON, such as + /// `2026`, still matches. + fn is_in(&self, line: &[u8]) -> bool { + match serde_json::from_slice(line) { + Ok(object @ Value::Object(_)) => self.is_in_strings_of(&object), + _ => self.is_in_text(&String::from_utf8_lossy(line)), + } + } + + fn is_in_strings_of(&self, value: &Value) -> bool { + match value { + Value::String(text) => self.is_in_text(text), + Value::Array(values) => values.iter().any(|value| self.is_in_strings_of(value)), + Value::Object(fields) => fields.values().any(|value| self.is_in_strings_of(value)), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } + } + + fn is_in_text(&self, text: &str) -> bool { + lowercase_for_matching(text).contains(&self.0) + } +} + +/// Lowercases `text`, with every sigma in its middle form. +/// +/// `str::to_lowercase` turns a capital sigma that ends a word into a final sigma, +/// so a query ending in "Σ" would not match the same letters inside a longer word. +fn lowercase_for_matching(text: &str) -> String { + let lowercase = text.to_lowercase(); + if lowercase.contains('ς') { + lowercase.replace('ς', "σ") + } else { + lowercase + } +} + +struct Search { + text: SearchText, + hits: Vec, + scanned_bytes: u64, + limit_reached: bool, +} + +impl Search { + fn new(text: SearchText) -> Self { + Self { + text, + hits: Vec::new(), + scanned_bytes: 0, + limit_reached: false, + } + } + + fn into_response(self) -> RecordingSearchResponse { + RecordingSearchResponse { + hits: self.hits, + limit_reached: self.limit_reached, + } + } + + /// Adds the lines of one log that match the search text. + async fn search_file(&mut self, recording_id: Uuid, file_name: &str, path: &Utf8Path) -> io::Result<()> { + let mut reader = BufReader::new(File::open(path).await?); + let mut line = Vec::new(); + let mut line_number = 0; + let mut offset = 0; + + loop { + if MAX_SCANNED_BYTES <= self.scanned_bytes { + self.limit_reached = !reader.fill_buf().await?.is_empty(); + return Ok(()); + } + + let Some(read) = read_line(&mut reader, &mut line).await? else { + return Ok(()); + }; + self.scanned_bytes += read.bytes; + line_number += 1; + let line_offset = offset; + offset += read.bytes; + + if self.text.is_in(&line) { + if MAX_HITS <= self.hits.len() { + self.limit_reached = true; + return Ok(()); + } + + self.hits.push(RecordingSearchHit { + recording_id, + file_name: file_name.to_owned(), + line_number, + offset: line_offset, + line: String::from_utf8_lossy(&line).into_owned(), + truncated: read.truncated, + }); + } + + if read.rest_unread { + let budget = MAX_SCANNED_BYTES.saturating_sub(self.scanned_bytes); + let skipped = skip_rest_of_line(&mut reader, budget).await?; + self.scanned_bytes += skipped.bytes; + offset += skipped.bytes; + + if !skipped.finished { + self.limit_reached = true; + return Ok(()); + } + } + } + } +} + +/// The `.slog` artifacts of one recording. +struct RecordingLogs { + recording_id: Uuid, + dir: Utf8PathBuf, + start_time: i64, + file_names: Vec, +} + +/// Lists the `.slog` artifacts of a recording, such as its AI analysis, from `recording.json`. +async fn read_recording_logs(dir: Utf8PathBuf, recording_id: Uuid) -> Option { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Manifest { + start_time: i64, + #[serde(default)] + artifacts: JrecArtifacts, + } + + let path = dir.join("recording.json"); + + // Every search reads every manifest again, so a failure here repeats on each search. + let json = tokio::fs::read(&path) + .await + .inspect_err(|error| trace!(%path, %error, "Failed to read a recording manifest")) + .ok()?; + + let manifest: Manifest = serde_json::from_slice(&json) + .inspect_err(|error| trace!(%path, %error, "Invalid recording manifest")) + .ok()?; + + // A log listed twice would be searched twice. + let mut seen = HashSet::new(); + let file_names = manifest + .artifacts + .into_file_names() + .into_iter() + .filter(|file_name| { + is_safe_recording_file_name(file_name) + && Utf8Path::new(file_name) + .extension() + .and_then(RecordingFileType::from_extension) + == Some(RecordingFileType::SessionRecordingLog) + && seen.insert(file_name.clone()) + }) + .collect(); + + Some(RecordingLogs { + recording_id, + dir, + start_time: manifest.start_time, + file_names, + }) +} + +/// Logs of every recording on this instance, newest first by manifest start time, then by recording ID. +async fn all_recording_logs_newest_first(recording_root: &Utf8Path) -> anyhow::Result> { + let mut read_dir = match tokio::fs::read_dir(recording_root).await { + Ok(read_dir) => read_dir, + // No recording has been made yet. + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + + let mut recordings = Vec::new(); + + while let Some(entry) = read_dir.next_entry().await? { + // Gateway names a recording folder by the hyphenated ID, and a copy under another spelling of the same ID + // would be searched as the same recording twice. + let Some(recording_id) = entry.file_name().to_str().and_then(|name| { + Uuid::parse_str(name) + .ok() + .filter(|recording_id| recording_id.to_string() == name) + }) else { + continue; + }; + + // A recording deleted while listing is skipped rather than failing the search. + if !entry.file_type().await.is_ok_and(|file_type| file_type.is_dir()) { + continue; + } + + let Ok(dir) = Utf8PathBuf::try_from(entry.path()) else { + continue; + }; + + recordings.extend(read_recording_logs(dir, recording_id).await); + } + + // Ordering by ID too keeps the order of recordings that start together the same on every request. + recordings.sort_unstable_by(|a, b| (b.start_time, b.recording_id).cmp(&(a.start_time, a.recording_id))); + + Ok(recordings) +} + +struct LineRead { + /// Bytes consumed, line break included. + bytes: u64, + /// The line is longer than [`MAX_LINE_BYTES`], so `line` holds only its start. + truncated: bool, + /// The rest of a truncated line, up to its line break, is still to be read. + rest_unread: bool, +} + +/// Reads the next line into `line`, without its line break, and returns `None` at the end of the file. +/// +/// A line longer than [`MAX_LINE_BYTES`] is cut to its start, at a character boundary. +/// +/// INVARIANT: on return, `line` holds at most [`MAX_LINE_BYTES`] bytes. +async fn read_line(reader: &mut BufReader, line: &mut Vec) -> io::Result> { + // Room for a line of exactly `MAX_LINE_BYTES` and its `\r\n`, so that it is not taken for a longer one. + const MAX_BUFFERED_BYTES: usize = MAX_LINE_BYTES + 2; + + line.clear(); + let mut bytes = 0; + let mut ended = false; + + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + if bytes == 0 { + return Ok(None); + } + + ended = true; + break; + } + + let room = MAX_BUFFERED_BYTES - line.len(); + let window = &available[..available.len().min(room)]; + let line_break = window.iter().position(|&byte| byte == b'\n'); + line.extend_from_slice(&window[..line_break.unwrap_or(window.len())]); + + let consumed = line_break.map_or(window.len(), |index| index + 1); + reader.consume(consumed); + bytes += u64::try_from(consumed).expect("buffer length fits in u64"); + + if line_break.is_some() { + ended = true; + break; + } + + if line.len() == MAX_BUFFERED_BYTES { + break; + } + } + + if ended && line.last() == Some(&b'\r') { + line.pop(); + } + + let truncated = MAX_LINE_BYTES < line.len(); + if truncated { + line.truncate(MAX_LINE_BYTES); + + let cut_character = line.utf8_chunks().last().map_or(0, |chunk| { + let invalid = chunk.invalid(); + let is_incomplete = std::str::from_utf8(invalid).is_err_and(|error| error.error_len().is_none()); + if is_incomplete { invalid.len() } else { 0 } + }); + line.truncate(line.len() - cut_character); + } + + Ok(Some(LineRead { + bytes, + truncated, + rest_unread: !ended, + })) +} + +struct Skipped { + /// Bytes consumed, line break included. + bytes: u64, + /// The line break or the end of the file was reached. + finished: bool, +} + +/// Consumes the rest of a line up to its line break, reading at most `budget` bytes, +/// so that a log without line breaks cannot make one search read all of it. +async fn skip_rest_of_line(reader: &mut BufReader, budget: u64) -> io::Result { + let mut bytes = 0; + + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok(Skipped { bytes, finished: true }); + } + + if budget <= bytes { + return Ok(Skipped { bytes, finished: false }); + } + + let room = usize::try_from(budget - bytes).unwrap_or(usize::MAX); + let window = &available[..available.len().min(room)]; + let line_break = window.iter().position(|&byte| byte == b'\n'); + + let consumed = line_break.map_or(window.len(), |index| index + 1); + reader.consume(consumed); + bytes += u64::try_from(consumed).expect("buffer length fits in u64"); + + if line_break.is_some() { + return Ok(Skipped { bytes, finished: true }); + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + async fn search_log(query: &str, content: &[u8]) -> Search { + let dir = tempfile::tempdir().expect("temp dir"); + let path = Utf8PathBuf::from_path_buf(dir.path().join("ai-analysis-0.slog")).expect("UTF-8 path"); + std::fs::write(&path, content).expect("write log"); + + let mut search = Search::new(SearchText::new(query).expect("non-empty query")); + search + .search_file(Uuid::nil(), "ai-analysis-0.slog", &path) + .await + .expect("search log"); + + search + } + + fn hit_lines(search: &Search) -> Vec<(u64, &str)> { + search + .hits + .iter() + .map(|hit| (hit.line_number, hit.line.as_str())) + .collect() + } + + #[rstest] + #[case::value_in_another_case("created USER")] + #[case::event_name("session.action")] + #[case::nested_value("ottawa/hull")] + #[case::value_in_an_array("audit trail")] + #[tokio::test] + async fn matches_the_string_values_of_a_json_line(#[case] query: &str) { + let line = r#"{"seq":1,"event":"session.action","description":"Created User","parameters":{"New name":"Help Desk Ottawa/Hull"},"tags":["Audit trail"]}"#; + + let search = search_log(query, line.as_bytes()).await; + + assert_eq!(hit_lines(&search), [(1, line)]); + } + + #[rstest] + #[case::field_name("description")] + #[case::nested_field_name("new name")] + #[case::number("12")] + #[case::boolean("true")] + #[case::null("null")] + #[tokio::test] + async fn does_not_match_the_field_names_or_other_values_of_a_json_line(#[case] query: &str) { + let line = + r#"{"seq":12,"done":true,"actor":null,"description":"Created User","parameters":{"New name":"Bob"}}"#; + + let search = search_log(query, line.as_bytes()).await; + + assert!(search.hits.is_empty()); + } + + #[rstest] + #[case::backslash(r"IT-HELP\Administrator", r#"{"description":"Reset IT-HELP\\Administrator"}"#)] + #[case::quote(r#""net user""#, r#"{"description":"Ran \"net user\""}"#)] + #[case::unicode_escape("créé", r#"{"description":"Utilisateur cr\u00e9\u00e9"}"#)] + #[tokio::test] + async fn matches_the_text_of_a_json_string_once_unescaped(#[case] query: &str, #[case] line: &str) { + let search = search_log(query, line.as_bytes()).await; + + assert_eq!(hit_lines(&search), [(1, line)]); + } + + #[tokio::test] + async fn matches_a_line_of_plain_text_that_parses_as_a_json_number() { + let search = search_log("2026", b"2026").await; + + assert_eq!(hit_lines(&search), [(1, "2026")]); + } + + #[tokio::test] + async fn matches_a_cut_line_that_no_longer_parses_as_raw_text() { + let line = format!(r#"{{"description":"{}"}}"#, "x".repeat(MAX_LINE_BYTES)); + + let search = search_log("description", line.as_bytes()).await; + + assert_eq!(search.hits.len(), 1); + assert!(search.hits[0].truncated); + } + + #[tokio::test] + async fn matches_a_cut_line_by_its_string_values_when_what_was_read_parses() { + let mut line = br#"{"description":"DOMAIN\\user"}"#.to_vec(); + line.resize(MAX_LINE_BYTES + 1, b' '); + + let search = search_log(r"DOMAIN\user", &line).await; + + assert_eq!(search.hits.len(), 1); + assert!(search.hits[0].truncated); + } + + #[rstest] + #[case::query_ending_in_a_capital_sigma("ΧΡΗΣ", "ΧΡΗΣΤΗΣ")] + #[case::line_ending_in_a_capital_sigma("σ", "ΟΣ")] + #[tokio::test] + async fn matches_a_sigma_wherever_it_falls_in_a_word(#[case] query: &str, #[case] line: &str) { + let search = search_log(query, line.as_bytes()).await; + + assert_eq!(hit_lines(&search), [(1, line)]); + } + + #[tokio::test] + async fn keeps_the_spaces_of_the_query() { + let search = search_log("user ", br#"{"name":"username"}"#).await; + + assert!(search.hits.is_empty()); + } + + #[tokio::test] + async fn returns_a_line_that_is_not_json() { + let search = search_log("sess", br#"{"seq":12,"event":"sess"#).await; + + assert_eq!(hit_lines(&search), [(1, r#"{"seq":12,"event":"sess"#)]); + } + + #[tokio::test] + async fn gives_the_line_number_and_byte_offset_of_each_hit() { + let search = search_log("hit", b"first\r\nsecond hit\r\nthird\r\nfourth hit\n").await; + + let positions: Vec<_> = search + .hits + .iter() + .map(|hit| (hit.line_number, hit.offset, hit.line.as_str())) + .collect(); + assert_eq!(positions, [(2, 7, "second hit"), (4, 26, "fourth hit")]); + } + + #[tokio::test] + async fn keeps_a_line_at_the_limit_ending_with_crlf_whole() { + let line = format!("hit{}", "x".repeat(MAX_LINE_BYTES - 3)); + + let search = search_log("hit", format!("{line}\r\n").as_bytes()).await; + + assert_eq!(search.hits.len(), 1); + assert_eq!(search.hits[0].line, line); + assert!(!search.hits[0].truncated); + } + + #[tokio::test] + async fn cuts_a_longer_line_and_keeps_reading_after_it() { + let content = format!("hit{}\nhit again\n", "x".repeat(MAX_LINE_BYTES)); + + let search = search_log("hit", content.as_bytes()).await; + + assert_eq!(search.hits.len(), 2); + assert_eq!(search.hits[0].line.len(), MAX_LINE_BYTES); + assert!(search.hits[0].truncated); + assert_eq!(hit_lines(&search)[1], (2, "hit again")); + } + + #[tokio::test] + async fn does_not_match_past_the_first_256_kib_of_a_line() { + let content = format!("{}hit\n", "x".repeat(MAX_LINE_BYTES)); + + let search = search_log("hit", content.as_bytes()).await; + + assert!(search.hits.is_empty()); + } + + #[rstest] + #[case::valid_text(b"hit".as_slice())] + #[case::after_an_invalid_byte(b"\xFFhi".as_slice())] + #[tokio::test] + async fn cuts_a_long_line_on_a_character_boundary(#[case] start: &[u8]) { + // Both starts are 3 bytes and "€" is 3 bytes, so the limit falls 1 byte into a character. + let mut content = start.to_vec(); + content.extend_from_slice("€".repeat(MAX_LINE_BYTES / 3).as_bytes()); + + let search = search_log("hi", &content).await; + + assert!(search.hits[0].truncated); + assert!(search.hits[0].line.ends_with('€')); + } + + #[tokio::test] + async fn stops_at_the_hit_limit() { + let content = "hit\n".repeat(MAX_HITS + 1); + + let search = search_log("hit", content.as_bytes()).await; + + assert_eq!(search.hits.len(), MAX_HITS); + assert!(search.limit_reached); + } + + #[tokio::test] + async fn stops_inside_a_line_without_breaks_at_the_read_budget() { + let budget = usize::try_from(MAX_SCANNED_BYTES).expect("budget fits in usize"); + let mut content = b"hit\n".to_vec(); + content.resize(content.len() + budget + 1024 * 1024, b'x'); + content.extend_from_slice(b"\nhit\n"); + + let search = search_log("hit", &content).await; + + assert_eq!(hit_lines(&search), [(1, "hit")]); + assert!(search.limit_reached); + assert!(search.scanned_bytes <= MAX_SCANNED_BYTES); + } + + #[rstest] + #[case::with_a_final_line_break(b"\n".as_slice())] + #[case::without_one(b"".as_slice())] + #[tokio::test] + async fn reaches_no_limit_when_the_read_budget_ends_with_the_log(#[case] end: &[u8]) { + let budget = usize::try_from(MAX_SCANNED_BYTES).expect("budget fits in usize"); + let mut content = b"hit\n".to_vec(); + content.resize(budget - end.len(), b'x'); + content.extend_from_slice(end); + + let search = search_log("hit", &content).await; + + assert_eq!(hit_lines(&search), [(1, "hit")]); + assert!(!search.limit_reached); + } + + #[tokio::test] + async fn lists_a_log_once_when_the_manifest_repeats_it() { + let root = tempfile::tempdir().expect("temp dir"); + std::fs::write( + root.path().join("recording.json"), + r#"{"startTime":100,"artifacts":{"ai-analysis":[{"fileName":"ai-analysis-0.slog"},{"fileName":"ai-analysis-0.slog"}]}}"#, + ) + .expect("write manifest"); + let dir = Utf8PathBuf::from_path_buf(root.path().to_owned()).expect("UTF-8 path"); + + let recording = read_recording_logs(dir, Uuid::nil()).await.expect("readable manifest"); + + assert_eq!(recording.file_names, ["ai-analysis-0.slog"]); + } + + /// Lists the recordings of a root holding a folder per name, each with a manifest that starts at the same time. + async fn list_recording_folders(names: impl IntoIterator) -> Vec { + let root = tempfile::tempdir().expect("temp dir"); + for name in names { + let dir = root.path().join(name); + std::fs::create_dir(&dir).expect("create recording folder"); + std::fs::write(dir.join("recording.json"), r#"{"startTime":100}"#).expect("write manifest"); + } + let root = Utf8Path::from_path(root.path()).expect("UTF-8 path"); + + let recordings = all_recording_logs_newest_first(root).await.expect("list recordings"); + + recordings.iter().map(|recording| recording.recording_id).collect() + } + + #[tokio::test] + async fn lists_recordings_that_start_together_highest_id_first() { + let ids = list_recording_folders((1..=3).map(|id| Uuid::from_u128(id).to_string())).await; + + assert_eq!(ids, [3, 2, 1].map(Uuid::from_u128)); + } + + #[tokio::test] + async fn lists_only_folders_named_by_the_hyphenated_id() { + let id = Uuid::from_u128(1); + + let ids = list_recording_folders([id.to_string(), id.simple().to_string(), id.braced().to_string()]).await; + + assert_eq!(ids, [id]); + } + + const SECOND: i128 = 1_000_000_000; + + fn at(unix_nanos: i128) -> Option { + Some(OffsetDateTime::from_unix_timestamp_nanos(unix_nanos).expect("valid time")) + } + + #[rstest] + #[case::from_is_included(at(100 * SECOND), None, true)] + #[case::from_a_fraction_later(at(100 * SECOND + SECOND / 2), None, false)] + #[case::to_is_excluded(None, at(100 * SECOND), false)] + #[case::to_a_fraction_later(None, at(100 * SECOND + 1), true)] + #[case::open(None, None, true)] + fn keeps_start_times_from_from_up_to_to( + #[case] from: Option, + #[case] to: Option, + #[case] contains: bool, + ) { + assert_eq!(StartTimeRange { from, to }.contains(100), contains); + } + + fn parse_request(fields: &str) -> serde_json::Result { + serde_json::from_str(&format!(r#"{{"query":"q",{fields}}}"#)) + } + + #[test] + fn parses_start_times_with_fractions_and_offsets() { + let request = parse_request( + r#""recordings":"all","startTime":{"from":"2026-10-10T00:00:00.000Z","to":"2026-10-10T02:00:00+02:00"}"#, + ) + .expect("valid request"); + + let start_time = request.start_time.expect("start time range"); + assert!(start_time.from.is_some()); + assert_eq!(start_time.from, start_time.to); + } + + #[test] + fn parses_all_recordings() { + let request = parse_request(r#""recordings":"all""#).expect("valid request"); + + assert!(matches!( + request.recordings, + RecordingSelection::All(AllRecordings::All) + )); + } + + #[rstest] + #[case::ids( + r#"["00000000-0000-0000-0000-000000000002","00000000-0000-0000-0000-000000000001"]"#, + vec![Uuid::from_u128(2), Uuid::from_u128(1)] + )] + #[case::empty("[]", Vec::new())] + fn parses_listed_recordings(#[case] recordings: &str, #[case] expected: Vec) { + let request = parse_request(&format!(r#""recordings":{recordings}"#)).expect("valid request"); + + let RecordingSelection::Listed(recording_ids) = request.recordings else { + panic!("expected a list of recording IDs"); + }; + assert_eq!(recording_ids, expected); + } + + #[rstest] + #[case::other_word(r#""some""#)] + #[case::all_in_a_list(r#"["all"]"#)] + fn rejects_other_recording_selections(#[case] recordings: &str) { + assert!(parse_request(&format!(r#""recordings":{recordings}"#)).is_err()); + } + + #[rstest] + #[case::in_the_request(r#""recordings":"all","mode":"regex""#)] + #[case::in_the_start_time(r#""recordings":"all","startTime":{"until":"2026-10-10T00:00:00Z"}"#)] + fn rejects_unknown_fields(#[case] fields: &str) { + assert!(parse_request(fields).is_err()); + } +} diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index b22ee0461..a000e46cf 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -321,6 +321,24 @@ where } } +#[derive(Clone, Copy)] +pub struct RecordingsSearchScope; + +impl FromRequestParts for RecordingsSearchScope +where + S: Send + Sync, +{ + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + match ScopeToken::from_request_parts(parts, state).await?.0.scope { + AccessScope::Wildcard => Ok(Self), + AccessScope::RecordingsSearch => Ok(Self), + _ => Err(HttpError::forbidden().msg("invalid scope for route")), + } + } +} + #[derive(Clone, Copy)] pub struct UpdateScope; diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 5052f6704..e023be143 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -24,6 +24,7 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; crate::api::jrec::list_recordings, crate::api::jrec::pull_recording_session, crate::api::jrec::pull_recording_file, + crate::api::jrec::search::search_recordings, crate::api::webapp::sign_app_token, crate::api::webapp::sign_session_token, crate::api::update::get_update_products, @@ -58,6 +59,11 @@ use crate::config::dto::{DataEncoding, PubKeyFormat, Subscriber}; ConfigPatch, crate::api::jrl::JrlInfo, crate::api::jrec::DeleteManyResult, + crate::api::jrec::search::RecordingSearchRequest, + crate::api::jrec::search::RecordingSelection, + crate::api::jrec::search::StartTimeRange, + crate::api::jrec::search::RecordingSearchResponse, + crate::api::jrec::search::RecordingSearchHit, crate::token::AccessScope, crate::api::webapp::AppTokenSignRequest, crate::api::webapp::AppTokenContentType, diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index d7aefef90..c922869a5 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -511,6 +511,8 @@ pub enum AccessScope { AgentDelete, #[serde(rename = "gateway.agent.read")] AgentRead, + #[serde(rename = "gateway.recordings.search")] + RecordingsSearch, } #[derive(Clone, Serialize, Deserialize)] diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index 66adf332e..5bd4e0c49 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -3,6 +3,7 @@ mod cli_args; mod heartbeat; mod kdc_proxy; mod preflight; +mod recording_search; mod subscriber; mod tls_anchoring; mod traffic_audit; diff --git a/testsuite/tests/cli/dgw/recording_search.rs b/testsuite/tests/cli/dgw/recording_search.rs new file mode 100644 index 000000000..150b54c60 --- /dev/null +++ b/testsuite/tests/cli/dgw/recording_search.rs @@ -0,0 +1,258 @@ +//! Recording log search API tests. + +use std::path::Path; + +use anyhow::Context as _; +use base64::Engine as _; +use testsuite::cli::start_dgw; +use testsuite::dgw_config::{DgwConfig, DgwConfigHandle}; +use uuid::Uuid; + +const OLDER_RECORDING: Uuid = Uuid::from_u128(1); +const NEWER_RECORDING: Uuid = Uuid::from_u128(2); + +/// Unsigned scope token; token validation is disabled in these tests. +fn scope_token(scope: &str) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = serde_json::json!({ "typ": "JWT", "alg": "RS256" }); + let payload = serde_json::json!({ + "type": "scope", + "jti": Uuid::new_v4().to_string(), + "iat": 1_733_669_999, + "nbf": 1_733_669_999, + "exp": 3_331_553_599i64, + "scope": scope, + }); + + format!( + "{}.{}.{}", + b64.encode(header.to_string()), + b64.encode(payload.to_string()), + b64.encode("signature-not-checked"), + ) +} + +fn write_recording(root: &Path, id: Uuid, manifest: serde_json::Value, files: &[(&str, &str)]) -> anyhow::Result<()> { + let dir = root.join(id.to_string()); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("recording.json"), manifest.to_string())?; + for (name, content) in files { + std::fs::write(dir.join(name), content)?; + } + Ok(()) +} + +/// A recording whose only log is the AI analysis artifact `ai-analysis-0.slog` holding `log`. +fn write_analysis_recording(root: &Path, id: Uuid, start_time: i64, log: &str) -> anyhow::Result<()> { + write_recording( + root, + id, + serde_json::json!({ + "sessionId": id.to_string(), + "startTime": start_time, + "duration": 10, + "files": [{ "fileName": "recording-0.webm", "startTime": start_time, "duration": 10 }], + "artifacts": { "ai-analysis": [{ "fileName": "ai-analysis-0.slog" }] }, + }), + &[("ai-analysis-0.slog", log)], + ) +} + +/// Two recordings whose AI analysis logs hold the searched text. +/// The older one also holds it in a recorded `.slog`, in its video, and in a log outside the recording folder that +/// its manifest points to, to show that only the `.slog` artifacts of a recording are searched. +/// Its manifest also lists a log that is gone, and a third recording folder has no manifest, to show that neither +/// fails the search. +fn write_recordings(root: &Path) -> anyhow::Result<()> { + std::fs::write( + root.join("outside.slog"), + r#"{"timestamp":"2026-07-15T16:15:35.140Z","seq":0,"event":"session.action","description":"Created User"}"#, + )?; + std::fs::create_dir(root.join(Uuid::from_u128(3).to_string()))?; + + write_recording( + root, + OLDER_RECORDING, + serde_json::json!({ + "sessionId": OLDER_RECORDING.to_string(), + "startTime": 100, + "duration": 10, + "files": [ + { "fileName": "recording-0.slog", "startTime": 100, "duration": 10 }, + { "fileName": "recording-1.webm", "startTime": 100, "duration": 10 }, + ], + "artifacts": { + "ai-analysis": [ + { "fileName": "ai-analysis-1.slog" }, + { "fileName": "ai-analysis-0.slog" }, + { "fileName": "../outside.slog" }, + ], + }, + }), + &[ + ( + "recording-0.slog", + r#"{"timestamp":"2026-07-15T16:15:41.054Z","seq":1,"event":"session.action","description":"Created User"}"#, + ), + ("recording-1.webm", "Created User"), + ( + "ai-analysis-0.slog", + concat!( + r#"{"timestamp":"2026-07-15T16:15:35.140Z","seq":0,"event":"session.start","description":"Session started"}"#, + "\n", + r#"{"timestamp":"2026-07-15T16:15:41.054Z","seq":1,"event":"session.action","description":"Created User","object":"Help Desk Ottawa"}"#, + "\n", + ), + ), + ], + )?; + + write_analysis_recording( + root, + NEWER_RECORDING, + 200, + r#"{"timestamp":"2026-07-16T09:00:01.000Z","seq":1,"event":"session.action","description":"Opened Users","parameters":{"Action":"Created user"}}"#, + ) +} + +/// Starts Gateway on `recordings`; the returned folder and process must outlive the test requests. +async fn start_on( + recordings: tempfile::TempDir, +) -> anyhow::Result<(tempfile::TempDir, DgwConfigHandle, tokio::process::Child)> { + let config_handle = DgwConfig::builder() + .disable_token_validation(true) + .recording_path(recordings.path().to_owned()) + .build() + .init()?; + let process = start_dgw(&config_handle).await?; + + Ok((recordings, config_handle, process)) +} + +/// Starts Gateway on the recordings above. +async fn start_with_recordings() -> anyhow::Result<(tempfile::TempDir, DgwConfigHandle, tokio::process::Child)> { + let recordings = tempfile::tempdir()?; + write_recordings(recordings.path())?; + + start_on(recordings).await +} + +async fn search(http_port: u16, scope: &str, request: serde_json::Value) -> anyhow::Result { + reqwest::Client::new() + .post(format!("http://127.0.0.1:{http_port}/jet/jrec/search")) + .bearer_auth(scope_token(scope)) + .json(&request) + .send() + .await + .context("send search request") +} + +/// `(recordingId, fileName, lineNumber)` of each hit. +fn hit_summaries(body: &serde_json::Value) -> anyhow::Result> { + body["hits"] + .as_array() + .context("hits")? + .iter() + .map(|hit| { + Ok(( + hit["recordingId"].as_str().context("recordingId")?.to_owned(), + hit["fileName"].as_str().context("fileName")?.to_owned(), + hit["lineNumber"].as_u64().context("lineNumber")?, + )) + }) + .collect() +} + +#[tokio::test] +async fn searches_the_slog_artifacts_of_every_recording_newest_first() -> anyhow::Result<()> { + let (_recordings, config_handle, _process) = start_with_recordings().await?; + + let response = search( + config_handle.http_port(), + "gateway.recordings.search", + serde_json::json!({ "query": "created USER", "recordings": "all" }), + ) + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let body: serde_json::Value = response.json().await?; + assert_eq!( + hit_summaries(&body)?, + [ + (NEWER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 1), + (OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2), + ] + ); + assert_eq!(body["limitReached"], false); + + Ok(()) +} + +#[tokio::test] +async fn searches_each_listed_recording_once() -> anyhow::Result<()> { + let (_recordings, config_handle, _process) = start_with_recordings().await?; + + let response = search( + config_handle.http_port(), + "gateway.recordings.search", + serde_json::json!({ + "query": "created user", + "recordings": [OLDER_RECORDING.to_string(), OLDER_RECORDING.to_string()], + }), + ) + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let body: serde_json::Value = response.json().await?; + assert_eq!( + hit_summaries(&body)?, + [(OLDER_RECORDING.to_string(), "ai-analysis-0.slog".to_owned(), 2)] + ); + + Ok(()) +} + +#[tokio::test] +async fn searches_only_the_recordings_started_in_the_time_range() -> anyhow::Result<()> { + let recordings = tempfile::tempdir()?; + for (id, start_time) in [(1, 100), (2, 200), (3, 300)] { + write_analysis_recording(recordings.path(), Uuid::from_u128(id), start_time, "Created User")?; + } + let (_recordings, config_handle, _process) = start_on(recordings).await?; + + // From the start time 200 up to, but not including, 300. + let response = search( + config_handle.http_port(), + "gateway.recordings.search", + serde_json::json!({ + "query": "created user", + "recordings": "all", + "startTime": { "from": "1970-01-01T00:03:20Z", "to": "1970-01-01T00:05:00Z" }, + }), + ) + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let body: serde_json::Value = response.json().await?; + assert_eq!( + hit_summaries(&body)?, + [(Uuid::from_u128(2).to_string(), "ai-analysis-0.slog".to_owned(), 1)] + ); + + Ok(()) +} + +#[tokio::test] +async fn rejects_a_token_without_the_search_scope() -> anyhow::Result<()> { + let (_recordings, config_handle, _process) = start_with_recordings().await?; + + let response = search( + config_handle.http_port(), + "gateway.recordings.read", + serde_json::json!({ "query": "created", "recordings": "all" }), + ) + .await?; + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + + Ok(()) +} diff --git a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs index eeb30c306..871dc7a23 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs @@ -219,6 +219,16 @@ public void ScopeClaimsAgentRead() Assert.Equal(EXPECTED, result); } + [Fact] + public void ScopeClaimsRecordingsSearch() + { + const string EXPECTED = """{"scope":"gateway.recordings.search","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}"""; + + var claims = new ScopeClaims(gatewayId, AccessScope.GatewayRecordingsSearch); + string result = JsonSerializer.Serialize(claims); + Assert.Equal(EXPECTED, result); + } + [Fact] public void EnrollmentClaimsAllFields() { diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs index bc953a2b1..880b86625 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AccessScope.cs @@ -30,6 +30,7 @@ internal AccessScope(string value) public static AccessScope GatewayNetMonitorDrain = new AccessScope("gateway.net.monitor.drain"); public static AccessScope GatewayAgentDelete = new AccessScope("gateway.agent.delete"); public static AccessScope GatewayAgentRead = new AccessScope("gateway.agent.read"); + public static AccessScope GatewayRecordingsSearch = new AccessScope("gateway.recordings.search"); public override string? ToString() {