본문으로 건너뛰기

pg_maintain 권한 위임 정리

· 약 8분

PostgreSQL 17은 테이블 수준 권한에 MAINTAIN을, predefined role에 pg_maintain을 추가했습니다. VACUUMANALYZE 같은 유지보수 명령을 소유자나 superuser가 아닌 계정에 위임하기 위한 권한입니다.

이 글의 모든 동작은 Docker 컨테이너(PostgreSQL 16.14 / 17.11 / 18.4, aarch64)에서 직접 실행해 확인한 결과입니다.

배경: PostgreSQL 16까지의 권한 모델

PostgreSQL 16까지 유지보수 명령의 실행 조건은 테이블 소유자, 데이터베이스 소유자, superuser 셋 중 하나였습니다. 중간 단계가 없었습니다.

소유자가 아닌 ops 계정으로 실행한 결과입니다.

SET ROLE ops;
VACUUM orders;
ANALYZE orders;
REINDEX TABLE orders;
WARNING: permission denied to vacuum "orders", skipping it
WARNING: permission denied to analyze "orders", skipping it
ERROR: must be owner of table orders

REINDEX는 에러로 멈추는데 VACUUMANALYZE는 경고만 남기고 넘어갑니다. 대상이 여러 개일 때 하나가 막혔다고 전체를 중단시키지 않으려는 설계입니다. 문제는 권한 자체가 없는 계정으로 데이터베이스 전체를 훑을 때입니다.

$ vacuumdb -U ops -d postgres -z
$ echo $?
0

경고 70건이 나왔는데 종료코드는 0입니다. 이 동작은 PostgreSQL 18.4까지 동일합니다.

그래서 PostgreSQL 16까지는 유지보수 배치 계정에 소유권을 넘기거나, superuser를 주거나, SECURITY DEFINER 함수로 감싸는 세 가지 우회책 중 하나를 골라야 했습니다. 셋 다 유지보수 하나 때문에 필요 이상의 권한이 따라옵니다.

MAINTAIN은 원래 PostgreSQL 16을 겨냥해 커밋됐다가 릴리스 전에 되돌려졌습니다. 되돌린 이유는 아래 search_path 절에서 따로 다룹니다.

기능 1: MAINTAIN 테이블 권한

ACL 약어는 m이고 대상 객체 타입은 테이블 계열입니다.

GRANT MAINTAIN ON TABLE orders TO ops;
relname | relacl
---------+------------------------------
orders | {app=arwdDxtm/app,ops=m/app}

이 권한으로 열리는 명령은 여섯 개입니다.

명령비고
VACUUMFULL 포함
ANALYZE통계 갱신
CLUSTER
REINDEX
REFRESH MATERIALIZED VIEWmaterialized view 자체에 부여해야 함
LOCK TABLE모드 제한 없음

GRANT ALL ON TABLE에도 포함됩니다. PostgreSQL 16의 arwdDxt가 17부터 arwdDxtm이 됩니다.

-- 16.14
grant_all_test16 | {app=arwdDxt/app,ops=arwdDxt/app}
-- 17.11
grant_all_test | {app=arwdDxtm/app,ops=arwdDxtm/app}

일괄 부여와 기본 권한 지정도 지원합니다.

GRANT MAINTAIN ON ALL TABLES IN SCHEMA public TO ops;
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public
GRANT MAINTAIN ON TABLES TO ops;

기능 2: pg_maintain predefined role

테이블마다 부여하는 대신 role 하나로 클러스터 전체를 덮습니다.

GRANT pg_maintain TO ops2;

모든 relation에 MAINTAIN이 있는 것처럼 동작하고, 범위에 시스템 카탈로그도 들어갑니다.

SET ROLE ops2;
REINDEX TABLE pg_catalog.pg_class; -- 성공

pg_maintain은 다른 predefined role의 멤버가 아닙니다. 이 role을 부여해도 pg_monitorpg_read_all_stats가 함께 붙지 않습니다.

기능 3: 유지보수 중 search_path 고정

PostgreSQL 16에서 되돌린 이유가 여기 있습니다. Nathan Bossart의 revert 커밋 메시지입니다.

A role with the MAINTAIN privilege may be able to use search_path tricks to escalate privileges to the table owner.

소유자 app이 index expression에 함수를 걸어 두고, opsMAINTAIN으로 REINDEX를 실행할 때 그 함수가 누구로 실행되는지 확인했습니다.

-- 소유자 app
CREATE FUNCTION trap2(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RAISE NOTICE '[trap] current_user=% / search_path=%',
current_user, current_setting('search_path');
RETURN i;
END $$;
CREATE INDEX small_trap_idx ON small (trap2(amt));

-- 유지보수 담당 ops
SET ROLE ops;
SET search_path TO evil, public;
REINDEX INDEX public.small_trap_idx;

PostgreSQL 17.11의 출력입니다.

NOTICE: [trap] current_user=app / search_path=pg_catalog, pg_temp

index expression은 명령을 실행한 ops가 아니라 테이블 소유자 app의 권한으로 평가됩니다. 그리고 ops가 세션에 지정한 evil, public은 무시되고 search_pathpg_catalog, pg_temp로 고정됩니다.

앞쪽 절반은 PostgreSQL 16도 같습니다. 뒤쪽 고정이 없으면 경로가 이렇게 열립니다.

ops는 자기가 만든 스키마를 search_path 앞에 두기만 하면 됩니다. 소유자의 함수가 스키마를 한정하지 않고 다른 함수를 호출하면 그 호출은 ops가 준비한 동명 함수로 연결되고, 결과적으로 app의 권한으로 실행됩니다. PostgreSQL 16 개발 주기가 늦어 기능 전체를 되돌렸고, Jeff Davis가 search_path 고정을 구현한 뒤 PostgreSQL 17에서 함께 들어왔습니다.

장점

읽기 권한과 유지보수 권한이 분리됩니다. MAINTAIN만 가진 계정으로 실행한 결과입니다.

VACUUM orders; -- 성공
ANALYZE orders; -- 성공
SELECT count(*) FROM orders; -- ERROR: permission denied for table orders
DELETE FROM orders WHERE id=1; -- ERROR: permission denied for table orders
TRUNCATE orders; -- ERROR: permission denied for table orders
ALTER TABLE orders ADD COLUMN x int; -- ERROR: must be owner of table orders
DROP TABLE orders; -- ERROR: must be owner of table orders

데이터를 한 줄도 읽지 못하는 계정이 그 테이블을 vacuum하고 통계를 갱신합니다.

통계도 새어 나가지 않습니다. pg_stats는 컬럼 단위 SELECT 권한을 확인하므로 MAINTAIN만 가진 계정에는 0행이 돌아옵니다. 히스토그램과 최빈값은 사실상 데이터 샘플이라 이 구분이 맞습니다.

표준 GRANT 체계에 들어와 있어서 ALTER DEFAULT PRIVILEGES로 신규 테이블까지 자동 적용되고, 권한 감사도 relaclm 하나로 끝납니다. 소유권을 옮기거나 superuser를 내주던 방식은 감사 추적이 어려웠습니다.

단점과 제약

파티션 테이블의 부모에 부여해도 자식 파티션으로 내려가지 않습니다. 그리고 VACUUM이라 에러도 나지 않습니다.

GRANT MAINTAIN ON TABLE evt TO ops; -- evt 는 파티션 부모

SET ROLE ops;
VACUUM evt;
-- WARNING: permission denied to vacuum "evt_2026_01", skipping it
-- WARNING: permission denied to vacuum "evt_2026_02", skipping it

파티션이 많으면 ALL TABLES IN SCHEMA로 덮거나 pg_maintain role을 쓰는 쪽이 현실적입니다.

materialized view도 별도입니다. 기반 테이블에 부여해도 REFRESH MATERIALIZED VIEW는 막히고, materialized view 자체에 다시 부여해야 통과합니다.

SET ROLE ops;
REFRESH MATERIALIZED VIEW mv_orders;
-- ERROR: permission denied for materialized view mv_orders

RESET ROLE;
GRANT MAINTAIN ON TABLE mv_orders TO ops; -- TABLE 키워드로 부여한다

LOCK TABLE이 포함되는데 모드 제한이 없습니다. 읽기 권한도 없는 계정이 운영 테이블을 ACCESS EXCLUSIVE로 잠글 수 있으므로, 유지보수 위임과 가용성 위험을 같이 저울질해야 합니다.

권한을 제대로 부여한 뒤에도 VACUUM의 조용한 skip 동작은 그대로입니다. 배치 쪽에서 종료코드 대신 경고 건수를 세는 편이 안전합니다.

search_path 고정은 MAINTAIN 권한을 쓰지 않아도 적용됩니다. ANALYZE, CLUSTER, CREATE INDEX, CREATE MATERIALIZED VIEW, REFRESH MATERIALIZED VIEW, REINDEX, VACUUM 전부가 대상이고 소유자 본인이 실행해도 같습니다. 그래서 이런 코드가 PostgreSQL 17에서 깨집니다.

CREATE FUNCTION helper(i int) RETURNS int LANGUAGE sql IMMUTABLE
AS 'SELECT i * 2';

CREATE FUNCTION expr(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN helper(i); -- 스키마를 한정하지 않았다
END $$;

CREATE INDEX t2_expr_idx ON t2 (expr(amt));
버전CREATE INDEX 결과
16.14성공, indisvalid = t
17.11ERROR: function helper(integer) does not exist
18.4ERROR: function helper(integer) does not exist

helperexpr이 같은 public 스키마에 있고 소유자가 직접 실행했는데도 실패합니다. search_pathpg_catalog, pg_temp라서 public이 검색 경로에 없기 때문입니다. 기존 인덱스를 재생성하거나 REINDEX를 실행하는 시점에 드러나므로, 업그레이드 직후가 아니라 한참 뒤 정기 유지보수 중에 발견되기 쉽습니다.

고치는 방법은 두 가지이고 둘 다 확인했습니다. 함수 정의에 검색 경로를 지정하거나,

CREATE FUNCTION expr_fixed(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE
SET search_path = public, pg_catalog
AS $$ BEGIN RETURN helper(i); END $$;

호출 쪽을 스키마로 한정합니다.

CREATE FUNCTION expr_qual(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE
AS $$ BEGIN RETURN public.helper(i); END $$;
경고

PostgreSQL 17로 올리기 전에 index expression과 materialized view 정의에 쓰인 사용자 정의 함수를 먼저 확인하는 편이 좋습니다. pg_proc에서 proconfig가 비어 있으면서 본문에 스키마 한정 없는 호출이 있는 함수가 후보입니다.

마지막으로, autovacuum은 이 권한 체계와 무관하게 동작합니다. pg_maintain을 부여한다고 autovacuum 쪽이 달라지지 않습니다.

17과 18의 차이

PostgreSQL 18에서 MAINTAIN의 범위가 넓어졌습니다. 통계 조작 함수가 이 권한으로 묶였습니다.

버전MAINTAIN 설명에 포함된 범위
17VACUUM, ANALYZE, CLUSTER, REFRESH MATERIALIZED VIEW, REINDEX, LOCK TABLE
18위 전부 + database object statistics manipulation functions
SET ROLE ops2; -- pg_maintain
SELECT pg_clear_relation_stats('public', 'orders'); -- 성공
SELECT pg_restore_relation_stats(
'schemaname', 'public', 'relname', 'orders',
'version', 180000, 'relpages', 42::integer); -- t

SET ROLE nobody;
SELECT pg_clear_relation_stats('public', 'orders');
-- ERROR: permission denied for table orders

읽지 못하는 테이블의 통계를 지우거나 원하는 값으로 덮어쓸 수 있다는 뜻입니다. 통계가 틀어지면 실행 계획이 틀어지므로, PostgreSQL 18에서 pg_maintain을 부여하는 판단은 17에서보다 무겁게 봐야 합니다.

같은 흐름에서 PostgreSQL 18은 pg_signal_autovacuum_worker role도 추가했습니다. 16과 17 컨테이너에는 없고 18.4에만 존재하는 것을 확인했습니다. autovacuum worker를 취소하거나 세션을 종료하는 권한이라, 유지보수 위임을 설계할 때 함께 검토할 대상입니다.

정리

MAINTAIN은 vacuum과 통계 갱신을 데이터 접근에서 떼어 낸 권한입니다. 유지보수 하나 때문에 소유권을 넘기거나 superuser를 내주던 구간이 없어졌습니다.

도입 전에 두 가지는 확인하고 가시는 게 좋아요. 하나는 search_path 고정 때문에 기존 index expression 함수가 깨지지 않는지, 다른 하나는 파티션과 materialized view처럼 권한이 상속되지 않는 곳이 배치에 섞여 있지 않은지입니다.

참고