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 before the argv is parsed at all.
54///
55/// A locked profile fixes the endpoint and the transport, so it must also refuse
56/// to create local files. This runs ahead of the registry rather than reading
57/// the resolved output plan, because failing closed here needs no agreement with
58/// the parser about which token is a flag: any `--stdout-file`/`--stderr-file`
59/// anywhere in argv — including where a value-skipping walk would read it as
60/// another argument's value — is refused. The redirect itself is installed only
61/// from a resolved plan, well after this guard has had its say.
62fn reject_stream_redirect(args: &[String]) -> Result<(), String> {
63    match agent_first_data::stream_redirect::config_from_raw_args(args.iter().cloned()) {
64        Ok(None) => Ok(()),
65        _ => Err(
66            "--stdout-file and --stderr-file are unavailable in afpsql-readonly because they create or truncate local files"
67                .to_string(),
68        ),
69    }
70}
71
72fn is_connection_or_transport_flag(flag: &str) -> bool {
73    matches!(
74        flag,
75        "--dsn"
76            | "--conninfo"
77            | "--host"
78            | "--port"
79            | "--user"
80            | "--dbname"
81            | "--password"
82            | "--ssh"
83            | "--ssh-via"
84            | "--ssh-option"
85            | "--ssh-remote-socket"
86            | "--ssh-sudo-user"
87            | "--container-docker-name"
88            | "--container-docker-user"
89            | "--container-docker-context"
90            | "--container-docker-runtime"
91            | "--container-podman-name"
92            | "--container-podman-user"
93            | "--container-podman-runtime"
94            | "--container-nerdctl-name"
95            | "--container-nerdctl-user"
96            | "--container-nerdctl-runtime"
97            | "--container-compose-service"
98            | "--container-compose-user"
99            | "--container-compose-file"
100            | "--container-compose-project"
101            | "--container-compose-runtime"
102            | "--container-kubectl-pod"
103            | "--container-kubectl-container"
104            | "--container-kubectl-namespace"
105            | "--container-kubectl-context"
106            | "--container-kubectl-runtime"
107    )
108}
109
110pub fn locked_profile_name(executable: &str) -> Result<Option<String>, String> {
111    let file_name = Path::new(executable)
112        .file_stem()
113        .and_then(|value| value.to_str())
114        .unwrap_or_default();
115    let Some(name) = file_name.strip_prefix(PROFILE_PREFIX) else {
116        return Ok(None);
117    };
118    if name.is_empty()
119        || !name
120            .bytes()
121            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
122    {
123        return Err(
124            "locked readonly profile name may contain only ASCII letters, digits, `-`, and `_`"
125                .to_string(),
126        );
127    }
128    Ok(Some(name.to_string()))
129}
130
131pub fn locked_profile_path(name: &str) -> PathBuf {
132    Path::new("/etc/afpsql/readonly-profiles").join(format!("{name}.json"))
133}
134
135pub fn load_locked_profile(name: &str) -> Result<SessionConfig, String> {
136    let path = locked_profile_path(name);
137    let metadata = std::fs::metadata(&path).map_err(|error| {
138        format!(
139            "cannot read locked readonly profile {}: {error}",
140            path.display()
141        )
142    })?;
143    if !metadata.is_file() || metadata.len() > PROFILE_MAX_BYTES {
144        return Err(format!(
145            "locked readonly profile {} must be a regular file no larger than {PROFILE_MAX_BYTES} bytes",
146            path.display()
147        ));
148    }
149    validate_profile_permissions(&path, &metadata)?;
150    let bytes = std::fs::read(&path).map_err(|error| {
151        format!(
152            "cannot read locked readonly profile {}: {error}",
153            path.display()
154        )
155    })?;
156    let session: SessionConfig = serde_json::from_slice(&bytes).map_err(|error| {
157        format!(
158            "invalid locked readonly profile {}: {error}",
159            path.display()
160        )
161    })?;
162    Ok(session)
163}
164
165#[cfg(unix)]
166fn validate_profile_permissions(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> {
167    use std::os::unix::fs::MetadataExt;
168    if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
169        return Err(format!(
170            "locked readonly profile {} must be owned by root and not writable by group or others",
171            path.display()
172        ));
173    }
174    Ok(())
175}
176
177#[cfg(not(unix))]
178fn validate_profile_permissions(path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> {
179    Err(format!(
180        "locked readonly profiles require Unix ownership checks; unsupported for {}",
181        path.display()
182    ))
183}
184
185fn is_opaque_value_flag(flag: &str) -> bool {
186    matches!(
187        flag,
188        "--sql"
189            | "--param"
190            | "--dsn"
191            | "--conninfo"
192            | "--password"
193            | "--host"
194            | "--port"
195            | "--user"
196            | "--dbname"
197            | "--ssh"
198            | "--ssh-via"
199            | "--ssh-option"
200            | "--ssh-remote-socket"
201            | "--ssh-sudo-user"
202            | "--container-docker-name"
203            | "--container-docker-user"
204            | "--container-docker-context"
205            | "--container-podman-name"
206            | "--container-podman-user"
207            | "--container-nerdctl-name"
208            | "--container-nerdctl-user"
209            | "--container-compose-service"
210            | "--container-compose-user"
211            | "--container-compose-file"
212            | "--container-compose-project"
213            | "--container-kubectl-pod"
214            | "--container-kubectl-container"
215            | "--container-kubectl-namespace"
216            | "--container-kubectl-context"
217            | "--permission"
218            | "--explain"
219            | "--mode"
220            | "--output"
221            | "--log"
222            | "--batch-rows"
223            | "--batch-bytes"
224            | "--statement-timeout-ms"
225            | "--lock-timeout-ms"
226            | "--inline-max-rows"
227            | "--inline-max-bytes"
228            | "--command"
229            | "--set"
230            | "-c"
231            | "-v"
232            | "-h"
233            | "-p"
234            | "-U"
235            | "-d"
236    )
237}
238
239fn is_value_flag(flag: &str) -> bool {
240    matches!(
241        flag,
242        "--stdout-file" | "--stderr-file" | "--sql-file" | "--file" | "-f"
243    ) || is_container_runtime_flag(flag)
244}
245
246/// Every driver family's runtime override: naming the executable is what makes
247/// container transport able to run an arbitrary command.
248fn is_container_runtime_flag(flag: &str) -> bool {
249    matches!(
250        flag,
251        "--container-docker-runtime"
252            | "--container-podman-runtime"
253            | "--container-nerdctl-runtime"
254            | "--container-compose-runtime"
255            | "--container-kubectl-runtime"
256    )
257}
258
259fn validate_raw_value(flag: &str, value: &str) -> Result<(), String> {
260    match flag {
261        "--stdout-file" | "--stderr-file" => Err(format!(
262            "{flag} is unavailable in afpsql-readonly because it can create or truncate local files"
263        )),
264        "--sql-file" | "--file" | "-f" if value != "-" => Err(format!(
265            "{flag} only accepts `-` in afpsql-readonly; use inline SQL or stdin"
266        )),
267        _ if is_container_runtime_flag(flag) => Err(format!(
268            "{flag} is unavailable in afpsql-readonly; each container driver family runs its own fixed runtime"
269        )),
270        _ => Ok(()),
271    }
272}
273
274pub fn validate_session(session: &SessionConfig) -> Result<(), String> {
275    validate_session_with_trust(session, false)
276}
277
278pub fn validate_session_with_trust(
279    session: &SessionConfig,
280    trusted_profile: bool,
281) -> Result<(), String> {
282    if session.container.runtime_override().is_some() && !trusted_profile {
283        return Err(
284            "custom container runtime is unavailable in afpsql-readonly; use a container driver family with its fixed runtime"
285                .to_string(),
286        );
287    }
288    for option in &session.ssh.options {
289        let key = option
290            .split_once('=')
291            .map_or(option.as_str(), |(key, _)| key)
292            .trim();
293        if !matches!(
294            key.to_ascii_lowercase().as_str(),
295            "connectionattempts"
296                | "connecttimeout"
297                | "port"
298                | "proxyjump"
299                | "serveralivecountmax"
300                | "serveraliveinterval"
301                | "tcpkeepalive"
302        ) {
303            return Err(format!(
304                "SSH option `{key}` is unavailable in afpsql-readonly; allowed options are ConnectionAttempts, ConnectTimeout, Port, ProxyJump, ServerAliveCountMax, ServerAliveInterval, and TCPKeepAlive"
305            ));
306        }
307    }
308    Ok(())
309}
310
311pub fn validate_sql(sql: &str) -> Result<(), String> {
312    let keywords = leading_keywords(sql, 4);
313    let is_transaction_control = matches!(
314        keywords.first().map(String::as_str),
315        Some("begin" | "commit" | "end" | "rollback" | "abort" | "savepoint" | "release")
316    ) || matches!(keywords.as_slice(), [first, second, ..]
317            if (first == "start" && second == "transaction")
318                || (first == "prepare" && second == "transaction")
319                || (first == "set" && second == "transaction"))
320        || matches!(keywords.as_slice(), [first, second, third, ..]
321            if first == "set"
322                && ((second == "session" && third == "characteristics")
323                    || (matches!(second.as_str(), "local" | "session")
324                        && third == "transaction")));
325    if is_transaction_control {
326        Err(
327            "transaction control SQL is unavailable in afpsql-readonly; use pipe begin/commit/rollback requests so the readonly state machine remains authoritative"
328                .to_string(),
329        )
330    } else {
331        Ok(())
332    }
333}
334
335fn leading_keywords(sql: &str, limit: usize) -> Vec<String> {
336    let bytes = sql.as_bytes();
337    let mut index = 0;
338    let mut words = Vec::with_capacity(limit);
339    while index < bytes.len() && words.len() < limit {
340        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
341            index += 1;
342        }
343        if bytes.get(index..index + 2) == Some(b"--") {
344            index += 2;
345            while index < bytes.len() && bytes[index] != b'\n' {
346                index += 1;
347            }
348            continue;
349        }
350        if bytes.get(index..index + 2) == Some(b"/*") {
351            index += 2;
352            let mut depth = 1usize;
353            while index < bytes.len() && depth > 0 {
354                if bytes.get(index..index + 2) == Some(b"/*") {
355                    depth += 1;
356                    index += 2;
357                } else if bytes.get(index..index + 2) == Some(b"*/") {
358                    depth -= 1;
359                    index += 2;
360                } else {
361                    index += 1;
362                }
363            }
364            continue;
365        }
366        let start = index;
367        while index < bytes.len() && (bytes[index].is_ascii_alphabetic() || bytes[index] == b'_') {
368            index += 1;
369        }
370        if start == index {
371            break;
372        }
373        words.push(sql[start..index].to_ascii_lowercase());
374    }
375    words
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::types::{ContainerConfig, SshConfig};
382
383    #[test]
384    fn ordinary_raw_policy_allows_host_capabilities() {
385        for args in [
386            vec!["afpsql-readonly", "--stdout-file", "/tmp/out"],
387            vec!["afpsql-readonly", "--stderr-file=/tmp/err"],
388            vec!["afpsql-readonly", "--sql-file", "/tmp/query.sql"],
389            vec!["afpsql-readonly", "--mode", "psql", "-f", "/tmp/query.sql"],
390            // A redirect flag placed where a value-skipping walk would treat it
391            // as the SQL/param value is still installed by the independent
392            // stream-redirect scanner, so it must be rejected in every form.
393            vec!["afpsql-readonly", "--sql", "--stdout-file=/tmp/out"],
394            vec!["afpsql-readonly", "--sql", "--stdout-file", "/tmp/out"],
395            vec!["afpsql-readonly", "--param", "x", "--stderr-file=/tmp/err"],
396        ] {
397            let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
398            assert!(validate_raw_args(&args).is_ok(), "rejected {args:?}");
399        }
400        assert!(
401            validate_raw_args(&["afpsql-readonly".to_string(), "--sql-file=-".to_string()]).is_ok()
402        );
403        for args in [
404            // `--sql-file` has no scanner independent of the CLI parser, so an
405            // inert value that merely looks like a flag stays inert.
406            vec!["afpsql-readonly", "-c", "--sql-file=/tmp/not-a-flag"],
407            vec![
408                "afpsql-readonly",
409                "--param",
410                "1=--container-docker-runtime=touch",
411            ],
412        ] {
413            let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
414            assert!(validate_raw_args(&args).is_ok(), "rejected value {args:?}");
415        }
416    }
417
418    #[test]
419    fn ordinary_raw_policy_allows_arbitrary_explicit_secret_env_names() {
420        for name in ["DATABASE_URL", "AFPSQL_DSN_SECRET", "AWS_SECRET_ACCESS_KEY"] {
421            assert!(
422                validate_raw_args(&["afpsql-readonly".to_string(), format!("--dsn=env:{name}")])
423                    .is_ok()
424            );
425        }
426    }
427
428    #[test]
429    fn locked_raw_policy_rejects_host_capabilities_in_any_order() {
430        for prohibited in [
431            vec!["--stdout-file", "/tmp/out"],
432            vec!["--sql-file", "/tmp/query.sql"],
433            vec!["--container-docker-runtime", "custom-runtime"],
434            vec!["--container-kubectl-runtime", "custom-runtime"],
435            vec!["--dsn", "env:AWS_SECRET_ACCESS_KEY"],
436        ] {
437            for args in [
438                [
439                    vec!["afpsql-readonly"],
440                    prohibited.clone(),
441                    vec!["--sql", "select 1"],
442                ]
443                .concat(),
444                [
445                    vec!["afpsql-readonly", "--sql", "select 1"],
446                    prohibited.clone(),
447                ]
448                .concat(),
449            ] {
450                let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
451                assert!(
452                    validate_raw_args_for_profile(&args, true).is_err(),
453                    "accepted {args:?}"
454                );
455            }
456        }
457    }
458
459    #[test]
460    fn readonly_session_policy_allows_only_safe_ssh_options_and_fixed_runtimes() {
461        let allowed = SessionConfig {
462            ssh: SshConfig {
463                options: vec![
464                    "ProxyJump=bastion".to_string(),
465                    "ConnectTimeout=5".to_string(),
466                ],
467                ..Default::default()
468            },
469            ..Default::default()
470        };
471        assert!(validate_session(&allowed).is_ok());
472
473        for option in [
474            "ProxyCommand=touch /tmp/pwned",
475            "LocalCommand=touch /tmp/pwned",
476            "Unknown=x",
477        ] {
478            let session = SessionConfig {
479                ssh: SshConfig {
480                    options: vec![option.to_string()],
481                    ..Default::default()
482                },
483                ..Default::default()
484            };
485            assert!(validate_session(&session).is_err(), "accepted {option}");
486        }
487
488        let custom_runtime = SessionConfig {
489            container: ContainerConfig {
490                compose_runtime: Some("touch".to_string()),
491                ..Default::default()
492            },
493            ..Default::default()
494        };
495        assert!(validate_session(&custom_runtime).is_err());
496        assert!(validate_session_with_trust(&custom_runtime, true).is_ok());
497    }
498
499    #[test]
500    fn sql_policy_classifies_transaction_control_without_blocking_normal_sql() {
501        for sql in [
502            "BEGIN",
503            "/* outer /* nested */ comment */ COMMIT",
504            "-- comment\nROLLBACK TO SAVEPOINT s",
505            "START TRANSACTION READ WRITE",
506            "SAVEPOINT s",
507            "RELEASE SAVEPOINT s",
508            "PREPARE TRANSACTION 'x'",
509            "SET TRANSACTION READ WRITE",
510            "SET LOCAL TRANSACTION READ WRITE",
511            "SET SESSION TRANSACTION READ WRITE",
512            "SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE",
513        ] {
514            assert!(validate_sql(sql).is_err(), "accepted {sql}");
515        }
516        for sql in [
517            "select 'commit'",
518            "select begin from keywords",
519            "set statement_timeout = 1000",
520            "notify channel",
521        ] {
522            assert!(validate_sql(sql).is_ok(), "rejected {sql}");
523        }
524    }
525
526    #[test]
527    fn locked_profile_is_selected_by_executable_and_rejects_overrides() {
528        assert_eq!(
529            locked_profile_name("/usr/local/bin/afpsql-readonly").ok(),
530            Some(None)
531        );
532        assert_eq!(
533            locked_profile_name("/usr/local/bin/afpsql-readonly-production").ok(),
534            Some(Some("production".to_string()))
535        );
536        assert!(locked_profile_name("afpsql-readonly-bad$name").is_err());
537        for flag in [
538            "--host",
539            "--ssh",
540            "--container-docker-runtime",
541            "--password",
542            "--dsn",
543        ] {
544            let args = vec![
545                "afpsql-readonly-production".to_string(),
546                flag.to_string(),
547                "value".to_string(),
548                "--sql".to_string(),
549                "select 1".to_string(),
550            ];
551            assert!(
552                validate_raw_args_for_profile(&args, true).is_err(),
553                "accepted {flag}"
554            );
555        }
556    }
557}