Skip to main content

agent_first_psql/
readonly_policy.rs

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
53/// Reject stdout/stderr redirection by mirroring the exact scanner the redirect
54/// installer runs.
55///
56/// `stream_redirect::install_from_raw_args` scans the raw argv independently of
57/// the CLI parser's value consumption, so it acts on a `--stdout-file` that a
58/// value-skipping walk treats as another flag's argument (for example
59/// `--sql --stdout-file=/path`). That install creates or appends to a local
60/// file before any capability check runs. Detecting the redirect with the same
61/// parser that would install it — and failing closed on its errors — guarantees
62/// the two never diverge, so no redirect target can be smuggled past this guard
63/// as an opaque flag value.
64fn 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            // A redirect flag placed where a value-skipping walk would treat it
361            // as the SQL/param value is still installed by the independent
362            // stream-redirect scanner, so it must be rejected in every form.
363            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            // `--sql-file` has no scanner independent of the CLI parser, so an
375            // inert value that merely looks like a flag stays inert.
376            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}