1use crate::types::SessionConfig;
2use std::path::{Path, PathBuf};
3
4const PROFILE_PREFIX: &str = "afpsql-readonly-";
5const PROFILE_MAX_BYTES: u64 = 65_536;
6
7pub fn validate_raw_args(args: &[String]) -> Result<(), String> {
8 validate_raw_args_for_profile(args, false)
9}
10
11pub fn validate_raw_args_for_profile(args: &[String], locked_profile: bool) -> Result<(), String> {
12 if !locked_profile {
13 return Ok(());
14 }
15 reject_stream_redirect(args)?;
16 let mut index = 1;
17 while index < args.len() {
18 let arg = &args[index];
19 if let Some((flag, value)) = arg.split_once('=') {
20 if locked_profile && is_connection_or_transport_flag(flag) {
21 return Err(locked_profile_override_error(flag));
22 }
23 if is_value_flag(flag) {
24 validate_raw_value(flag, value)?;
25 }
26 index += 1;
27 continue;
28 }
29 if locked_profile && is_connection_or_transport_flag(arg) {
30 return Err(locked_profile_override_error(arg));
31 }
32 if is_opaque_value_flag(arg) {
33 index += 2;
34 continue;
35 }
36 if is_value_flag(arg) {
37 let value = args
38 .get(index + 1)
39 .ok_or_else(|| format!("{arg} requires a value"))?;
40 validate_raw_value(arg, value)?;
41 index += 2;
42 continue;
43 }
44 index += 1;
45 }
46 Ok(())
47}
48
49fn locked_profile_override_error(flag: &str) -> String {
50 format!("{flag} cannot override an administrator-locked afpsql-readonly profile")
51}
52
53fn reject_stream_redirect(args: &[String]) -> Result<(), String> {
65 match agent_first_data::stream_redirect::config_from_raw_args(args.iter().cloned()) {
66 Ok(None) => Ok(()),
67 _ => Err(
68 "--stdout-file and --stderr-file are unavailable in afpsql-readonly because they create or truncate local files"
69 .to_string(),
70 ),
71 }
72}
73
74fn is_connection_or_transport_flag(flag: &str) -> bool {
75 matches!(
76 flag,
77 "--dsn-secret"
78 | "--dsn-secret-env"
79 | "--dsn-secret-config"
80 | "--conninfo-secret"
81 | "--conninfo-secret-env"
82 | "--conninfo-secret-config"
83 | "--host"
84 | "--port"
85 | "--user"
86 | "--dbname"
87 | "--password-secret"
88 | "--password-secret-env"
89 | "--password-secret-config"
90 | "--ssh"
91 | "--ssh-via"
92 | "--ssh-option"
93 | "--ssh-local-host"
94 | "--ssh-local-port"
95 | "--ssh-remote-socket"
96 | "--ssh-sudo-user"
97 | "--container"
98 | "--container-driver"
99 | "--container-runtime"
100 | "--container-user"
101 | "--container-namespace"
102 | "--container-context"
103 | "--container-compose-file"
104 | "--container-compose-project"
105 | "--container-pod-container"
106 )
107}
108
109pub fn locked_profile_name(executable: &str) -> Result<Option<String>, String> {
110 let file_name = Path::new(executable)
111 .file_stem()
112 .and_then(|value| value.to_str())
113 .unwrap_or_default();
114 let Some(name) = file_name.strip_prefix(PROFILE_PREFIX) else {
115 return Ok(None);
116 };
117 if name.is_empty()
118 || !name
119 .bytes()
120 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
121 {
122 return Err(
123 "locked readonly profile name may contain only ASCII letters, digits, `-`, and `_`"
124 .to_string(),
125 );
126 }
127 Ok(Some(name.to_string()))
128}
129
130pub fn locked_profile_path(name: &str) -> PathBuf {
131 Path::new("/etc/afpsql/readonly-profiles").join(format!("{name}.json"))
132}
133
134pub fn load_locked_profile(name: &str) -> Result<SessionConfig, String> {
135 let path = locked_profile_path(name);
136 let metadata = std::fs::metadata(&path).map_err(|error| {
137 format!(
138 "cannot read locked readonly profile {}: {error}",
139 path.display()
140 )
141 })?;
142 if !metadata.is_file() || metadata.len() > PROFILE_MAX_BYTES {
143 return Err(format!(
144 "locked readonly profile {} must be a regular file no larger than {PROFILE_MAX_BYTES} bytes",
145 path.display()
146 ));
147 }
148 validate_profile_permissions(&path, &metadata)?;
149 let bytes = std::fs::read(&path).map_err(|error| {
150 format!(
151 "cannot read locked readonly profile {}: {error}",
152 path.display()
153 )
154 })?;
155 let session: SessionConfig = serde_json::from_slice(&bytes).map_err(|error| {
156 format!(
157 "invalid locked readonly profile {}: {error}",
158 path.display()
159 )
160 })?;
161 Ok(session)
162}
163
164#[cfg(unix)]
165fn validate_profile_permissions(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> {
166 use std::os::unix::fs::MetadataExt;
167 if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
168 return Err(format!(
169 "locked readonly profile {} must be owned by root and not writable by group or others",
170 path.display()
171 ));
172 }
173 Ok(())
174}
175
176#[cfg(not(unix))]
177fn validate_profile_permissions(path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> {
178 Err(format!(
179 "locked readonly profiles require Unix ownership checks; unsupported for {}",
180 path.display()
181 ))
182}
183
184fn is_opaque_value_flag(flag: &str) -> bool {
185 matches!(
186 flag,
187 "--sql"
188 | "--param"
189 | "--dsn-secret"
190 | "--conninfo-secret"
191 | "--password-secret"
192 | "--host"
193 | "--port"
194 | "--user"
195 | "--dbname"
196 | "--ssh"
197 | "--ssh-via"
198 | "--ssh-option"
199 | "--ssh-local-host"
200 | "--ssh-local-port"
201 | "--ssh-remote-socket"
202 | "--ssh-sudo-user"
203 | "--container"
204 | "--container-driver"
205 | "--container-user"
206 | "--container-namespace"
207 | "--container-context"
208 | "--container-compose-file"
209 | "--container-compose-project"
210 | "--container-pod-container"
211 | "--permission"
212 | "--mode"
213 | "--output"
214 | "--log"
215 | "--batch-rows"
216 | "--batch-bytes"
217 | "--statement-timeout-ms"
218 | "--lock-timeout-ms"
219 | "--inline-max-rows"
220 | "--inline-max-bytes"
221 | "--command"
222 | "--set"
223 | "-c"
224 | "-v"
225 | "-h"
226 | "-p"
227 | "-U"
228 | "-d"
229 )
230}
231
232fn is_value_flag(flag: &str) -> bool {
233 matches!(
234 flag,
235 "--stdout-file"
236 | "--stderr-file"
237 | "--sql-file"
238 | "--file"
239 | "-f"
240 | "--container-runtime"
241 | "--dsn-secret-env"
242 | "--conninfo-secret-env"
243 | "--password-secret-env"
244 )
245}
246
247fn validate_raw_value(flag: &str, value: &str) -> Result<(), String> {
248 match flag {
249 "--stdout-file" | "--stderr-file" => Err(format!(
250 "{flag} is unavailable in afpsql-readonly because it can create or truncate local files"
251 )),
252 "--sql-file" | "--file" | "-f" if value != "-" => Err(format!(
253 "{flag} only accepts `-` in afpsql-readonly; use inline SQL or stdin"
254 )),
255 "--container-runtime" => Err(
256 "--container-runtime is unavailable in afpsql-readonly; select a typed --container-driver with its fixed runtime"
257 .to_string(),
258 ),
259 "--dsn-secret-env" if !matches!(value, "DATABASE_URL" | "AFPSQL_DSN_SECRET") => {
260 Err("--dsn-secret-env in afpsql-readonly only accepts DATABASE_URL or AFPSQL_DSN_SECRET".to_string())
261 }
262 "--conninfo-secret-env" if value != "AFPSQL_CONNINFO_SECRET" => Err(
263 "--conninfo-secret-env in afpsql-readonly only accepts AFPSQL_CONNINFO_SECRET"
264 .to_string(),
265 ),
266 "--password-secret-env" if !matches!(value, "PGPASSWORD" | "AFPSQL_PASSWORD_SECRET") => {
267 Err("--password-secret-env in afpsql-readonly only accepts PGPASSWORD or AFPSQL_PASSWORD_SECRET".to_string())
268 }
269 _ => Ok(()),
270 }
271}
272
273pub fn validate_session(session: &SessionConfig) -> Result<(), String> {
274 validate_session_with_trust(session, false)
275}
276
277pub fn validate_session_with_trust(
278 _session: &SessionConfig,
279 _trusted_profile: bool,
280) -> Result<(), String> {
281 Ok(())
282}
283
284pub fn validate_sql(sql: &str) -> Result<(), String> {
285 let keywords = leading_keywords(sql, 3);
286 let is_transaction_control = matches!(
287 keywords.first().map(String::as_str),
288 Some("begin" | "commit" | "end" | "rollback" | "abort" | "savepoint" | "release")
289 ) || matches!(keywords.as_slice(), [first, second, ..]
290 if (first == "start" && second == "transaction")
291 || (first == "prepare" && second == "transaction")
292 || (first == "set" && second == "transaction"))
293 || matches!(keywords.as_slice(), [first, second, third, ..]
294 if first == "set" && second == "session" && third == "characteristics");
295 if is_transaction_control {
296 Err(
297 "transaction control SQL is unavailable in afpsql-readonly; use pipe begin/commit/rollback requests so the readonly state machine remains authoritative"
298 .to_string(),
299 )
300 } else {
301 Ok(())
302 }
303}
304
305fn leading_keywords(sql: &str, limit: usize) -> Vec<String> {
306 let bytes = sql.as_bytes();
307 let mut index = 0;
308 let mut words = Vec::with_capacity(limit);
309 while index < bytes.len() && words.len() < limit {
310 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
311 index += 1;
312 }
313 if bytes.get(index..index + 2) == Some(b"--") {
314 index += 2;
315 while index < bytes.len() && bytes[index] != b'\n' {
316 index += 1;
317 }
318 continue;
319 }
320 if bytes.get(index..index + 2) == Some(b"/*") {
321 index += 2;
322 let mut depth = 1usize;
323 while index < bytes.len() && depth > 0 {
324 if bytes.get(index..index + 2) == Some(b"/*") {
325 depth += 1;
326 index += 2;
327 } else if bytes.get(index..index + 2) == Some(b"*/") {
328 depth -= 1;
329 index += 2;
330 } else {
331 index += 1;
332 }
333 }
334 continue;
335 }
336 let start = index;
337 while index < bytes.len() && (bytes[index].is_ascii_alphabetic() || bytes[index] == b'_') {
338 index += 1;
339 }
340 if start == index {
341 break;
342 }
343 words.push(sql[start..index].to_ascii_lowercase());
344 }
345 words
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::types::{ContainerConfig, SshConfig};
352
353 #[test]
354 fn ordinary_raw_policy_allows_host_capabilities() {
355 for args in [
356 vec!["afpsql-readonly", "--stdout-file", "/tmp/out"],
357 vec!["afpsql-readonly", "--stderr-file=/tmp/err"],
358 vec!["afpsql-readonly", "--sql-file", "/tmp/query.sql"],
359 vec!["afpsql-readonly", "--mode", "psql", "-f", "/tmp/query.sql"],
360 vec!["afpsql-readonly", "--sql", "--stdout-file=/tmp/out"],
364 vec!["afpsql-readonly", "--sql", "--stdout-file", "/tmp/out"],
365 vec!["afpsql-readonly", "--param", "x", "--stderr-file=/tmp/err"],
366 ] {
367 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
368 assert!(validate_raw_args(&args).is_ok(), "rejected {args:?}");
369 }
370 assert!(
371 validate_raw_args(&["afpsql-readonly".to_string(), "--sql-file=-".to_string()]).is_ok()
372 );
373 for args in [
374 vec!["afpsql-readonly", "-c", "--sql-file=/tmp/not-a-flag"],
377 vec!["afpsql-readonly", "--param", "1=--container-runtime=touch"],
378 ] {
379 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
380 assert!(validate_raw_args(&args).is_ok(), "rejected value {args:?}");
381 }
382 }
383
384 #[test]
385 fn ordinary_raw_policy_allows_arbitrary_explicit_secret_env_names() {
386 for name in ["DATABASE_URL", "AFPSQL_DSN_SECRET", "AWS_SECRET_ACCESS_KEY"] {
387 assert!(
388 validate_raw_args(&[
389 "afpsql-readonly".to_string(),
390 format!("--dsn-secret-env={name}")
391 ])
392 .is_ok()
393 );
394 }
395 }
396
397 #[test]
398 fn locked_raw_policy_rejects_host_capabilities_in_any_order() {
399 for prohibited in [
400 vec!["--stdout-file", "/tmp/out"],
401 vec!["--sql-file", "/tmp/query.sql"],
402 vec!["--container-runtime", "custom-runtime"],
403 vec!["--dsn-secret-env", "AWS_SECRET_ACCESS_KEY"],
404 ] {
405 for args in [
406 [
407 vec!["afpsql-readonly"],
408 prohibited.clone(),
409 vec!["--sql", "select 1"],
410 ]
411 .concat(),
412 [
413 vec!["afpsql-readonly", "--sql", "select 1"],
414 prohibited.clone(),
415 ]
416 .concat(),
417 ] {
418 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
419 assert!(
420 validate_raw_args_for_profile(&args, true).is_err(),
421 "accepted {args:?}"
422 );
423 }
424 }
425 }
426
427 #[test]
428 fn ordinary_session_policy_allows_ssh_options_and_custom_runtime() {
429 let allowed = SessionConfig {
430 ssh: SshConfig {
431 options: vec![
432 "ProxyJump=bastion".to_string(),
433 "ConnectTimeout=5".to_string(),
434 ],
435 ..Default::default()
436 },
437 ..Default::default()
438 };
439 assert!(validate_session(&allowed).is_ok());
440
441 for option in [
442 "ProxyCommand=touch /tmp/pwned",
443 "LocalCommand=touch /tmp/pwned",
444 "Unknown=x",
445 ] {
446 let session = SessionConfig {
447 ssh: SshConfig {
448 options: vec![option.to_string()],
449 ..Default::default()
450 },
451 ..Default::default()
452 };
453 assert!(validate_session(&session).is_ok(), "rejected {option}");
454 }
455
456 let custom_runtime = SessionConfig {
457 container: ContainerConfig {
458 runtime: Some("touch".to_string()),
459 ..Default::default()
460 },
461 ..Default::default()
462 };
463 assert!(validate_session(&custom_runtime).is_ok());
464 }
465
466 #[test]
467 fn sql_policy_classifies_transaction_control_without_blocking_normal_sql() {
468 for sql in [
469 "BEGIN",
470 "/* outer /* nested */ comment */ COMMIT",
471 "-- comment\nROLLBACK TO SAVEPOINT s",
472 "START TRANSACTION READ WRITE",
473 "SAVEPOINT s",
474 "RELEASE SAVEPOINT s",
475 "PREPARE TRANSACTION 'x'",
476 "SET TRANSACTION READ WRITE",
477 "SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE",
478 ] {
479 assert!(validate_sql(sql).is_err(), "accepted {sql}");
480 }
481 for sql in [
482 "select 'commit'",
483 "select begin from keywords",
484 "set statement_timeout = 1000",
485 "notify channel",
486 ] {
487 assert!(validate_sql(sql).is_ok(), "rejected {sql}");
488 }
489 }
490
491 #[test]
492 fn locked_profile_is_selected_by_executable_and_rejects_overrides() {
493 assert_eq!(
494 locked_profile_name("/usr/local/bin/afpsql-readonly").ok(),
495 Some(None)
496 );
497 assert_eq!(
498 locked_profile_name("/usr/local/bin/afpsql-readonly-production").ok(),
499 Some(Some("production".to_string()))
500 );
501 assert!(locked_profile_name("afpsql-readonly-bad$name").is_err());
502 for flag in [
503 "--host",
504 "--ssh",
505 "--container-runtime",
506 "--password-secret-env",
507 "--dsn-secret-config",
508 ] {
509 let args = vec![
510 "afpsql-readonly-production".to_string(),
511 flag.to_string(),
512 "value".to_string(),
513 "--sql".to_string(),
514 "select 1".to_string(),
515 ];
516 assert!(
517 validate_raw_args_for_profile(&args, true).is_err(),
518 "accepted {flag}"
519 );
520 }
521 }
522}