我在維護一個開源(MIT)、可以自架的網頁版資料庫工具 LibreDB Studio,最近在做「AI Agent 只能讀資料庫」的功能,遇到一個 PostgreSQL 權限的問題想請教。
在 PostgreSQL 18 上實測,用 superuser 連線時,下面三種操作在 BEGIN READ ONLY 裡都會成功:
COPY (...) TO '/path/to/file':把查詢結果寫到伺服器上的檔案COPY (...) TO PROGRAM 'cmd':用資料庫的作業系統帳號執行 shell 指令SELECT pg_read_file('/path/to/file'):讀取伺服器上的檔案我知道標準答案是「不要給 superuser」。我們的難處是:連線帳號是使用者自己填的,工具端要怎麼確認它真的是低權限?
目前的做法是建立連線時先檢查角色,只要是 superuser,或是 pg_read_server_files、pg_write_server_files、pg_execute_server_program 其中任何一個的成員,就拒絕連線。另外,每個交易只允許跑一條 statement,跑完一律 rollback,所以就算 SET TRANSACTION READ WRITE 生效了,後面也沒有第二句可以寫入。
這樣還有兩個缺口我自己也覺得不太妥。一是檢查只在建立連線時做一次,之後如果角色被加了權限,連線池裡已經通過檢查的連線還是會繼續被拿來用,直到閒置被清掉為止。二是它檢查的是角色成員身分,不是實際權限:如果有人直接 GRANT EXECUTE ON FUNCTION pg_read_file(text),四項檢查(superuser 加三個角色)都會是 false,但它還是讀得到資料目錄和 log 目錄裡的檔案。
想請教一下:大家讓 BI 或 AI Agent 連 PostgreSQL 時,防線是設在交易、角色,還是兩邊都做?上面兩個缺口有沒有比較好的補法,例如每次取用連線時重新檢查,或改用 has_function_privilege 直接查?除了這三個,還有哪些不需要 superuser 也碰得到伺服器的路?
我們建議的角色設定寫在這裡:PostgreSQL 唯讀設計說明
BEGIN READ ONLY 是交易層的防呆,不是安全邊界;你測到的現象合理。伺服器端 COPY ... TO file/PROGRAM 以及 pg_read_file() 這類路徑,重點是連線角色實際持有的權限,而不是交易是不是唯讀。PostgreSQL 官方文件也特別提醒,pg_read_server_files、pg_write_server_files、pg_execute_server_program 可繞過一般資料庫層權限檢查,應只授給可信任的管理角色。
我會把防線拆兩層:
角色層做硬隔離:Agent 用專屬的非 superuser role,不授予上述三個預設角色,也不要讓它能 SET ROLE 到高權限角色;資料表只給必要 schema/table 的 SELECT。另檢查敏感函式的有效 EXECUTE 權限,例如 has_function_privilege(),不能只看角色是否為三個預設角色成員。對不該讓一般角色呼叫的函式,確認直接/經 PUBLIC 繼承的授權並移除不必要的 EXECUTE;也要盤點 extension、SECURITY DEFINER 函式及其他可觸及外部資源的入口。
交易層做縱深防禦:每次執行仍用唯讀交易、限制 statement timeout,並確保一次只接受一條 statement;但不要把 rollback 或 READ ONLY 當成阻止伺服器檔案/命令操作的保證。若工具只需要查詢,最好把 SQL 限制在明確允許的查詢集合,或送到隔離的唯讀 replica/專用資料服務,而非讓模型直接拿任意 SQL 的資料庫連線。
連線池方面,建立連線時檢查不夠:權限變更後既有 session 還可能存活。至少在每次借出/執行前確認 role 與必要權限,並在角色或授權異動時淘汰相關 pool session;最穩的作法是權限變更就撤銷/重建該 Agent 的連線。你提到的直接 GRANT EXECUTE ON FUNCTION pg_read_file(...) 正是只檢查角色旗標會漏掉的例子。
總結:角色權限是主要安全邊界,唯讀交易是額外保護。工具端的預檢只能降低誤用風險,不能取代資料庫端最小權限。可再用專用低權限 role 實際測試 COPY ... PROGRAM、檔案型 COPY、pg_read_file() 及相關函式,確認拒絕結果符合預期。
參考:PostgreSQL COPY 文件 https://www.postgresql.org/docs/current/sql-copy.html ;預設角色文件 https://www.postgresql.org/docs/current/predefined-roles.html
補幾個前面回答沒講到的點,都是針對你預檢的寫法。
1. 查成員身分不夠,要分開查 USAGE 和 SET。 pg_has_role(current_user, 'pg_read_server_files', 'MEMBER') 只代表在不在角色鏈裡。依文件,USAGE 是「不用 SET ROLE 就直接擁有該角色權限」,SET 是「能不能 SET ROLE 過去」。NOINHERIT 的帳號 USAGE 是 false,但 SET 可能是 true,一句 SET ROLE 就拿到權限。所以三個預設角色和任何高權限角色,這兩項都要查,只看其中一項會漏。
2. 函式要查有效權限,而且清單會比你列的長。 has_function_privilege(current_user, 'pg_read_file(text)', 'EXECUTE') 會把直接 GRANT 和 PUBLIC 繼承都算進去,剛好補你說的第二個缺口。至少再加 pg_read_binary_file、pg_ls_dir、lo_import(text)、lo_export(oid,text)。文件寫明 server-side 的 lo_import/lo_export 預設只給 superuser,但可以 GRANT 給一般帳號,不用 superuser 一樣讀寫伺服器檔案。新版本還有 has_parameter_privilege(current_user, 'archive_command', 'ALTER SYSTEM') 這類參數權限,能碰設定就有機會間接執行指令。
3. 但這份清單是黑名單,終究會漏。 新版本、extension、SECURITY DEFINER 函式都會長出新入口。比較穩的是反過來:工具產一段建立專用 role 的 SQL(只 GRANT 指定 schema 的 SELECT),預檢時確認連線帳號就是那個 role,再用上面的檢查當保險。使用者自己填的任意帳號,只能做到「盡量擋」,擋不到「保證」。
4. 連線池這邊,每次借出時重查是便宜的,一條 SQL 就能把 rolsuper、pg_has_role 的 USAGE/SET、has_function_privilege 一次查完,借出時跑一次。但檢查和執行之間還是有空窗,所以你現在「一個 transaction 一條 statement、一律 rollback」的設計要留著,兩個是互補,不是二選一。
我覺得角色權限跟交易層的都要做,但我會把這兩個當成第一道防線,不是唯一的防線。
角色權限設得再細還是有失控的可能,你情況還要加上無法控制使用者自己填的,這一段根本就管不到,等於這道防線有一半不在你手上。
所以我會建議後面再多幾道防線,直接限制 AI 本身的動作,不是從權限去限制它能不能做,是看它實際要做的這個動作合不合理,一旦失控就直接擋下來。