Sebelumnya kita memberi hak pada tabel, padahal tabel tidak berdiri sendiri: setiap tabel hidup di dalam sebuah schema, dan schema itu adalah gerbang yang harus dilewati lebih dulu. Pemeriksaan akses PostgreSQL bekerja berlapis — database, lalu schema, lalu objek — sehingga GRANT SELECT pada tabel tidak bernilai apa-apa bila role tidak boleh “masuk” ke schema-nya. Perubahan besar PostgreSQL 15 juga terjadi tepat di level ini: hak membuat objek di schema public dicabut dari PUBLIC, mematahkan kebiasaan lama yang menganggap schema bawaan sebagai halaman bebas tulis. Chapter ini menyelami kedua hak schema (USAGE dan CREATE), membangun pola schema bersama dengan ALTER DEFAULT PRIVILEGES agar tabel baru otomatis terbaca anggota tim, lalu menutup dengan catatan keamanan search_path yang sering diabaikan.

Schema sebagai Ruang Nama #

Fungsi pertama schema bersifat organisatoris: memisahkan objek ke ruang nama masing-masing sehingga dua tabel boleh bernama sama tanpa bertabrakan. File pembuka membuat schema stok dan katalog yang masing-masing memuat tabel barang dengan struktur berbeda:

sql buat-schema.sql
-- Schema sebagai ruang nama: dua schema boleh memakai
-- nama tabel yang sama tanpa saling bertabrakan.
DROP ROLE IF EXISTS kasir_tmj;

CREATE ROLE kasir_tmj LOGIN;

CREATE SCHEMA stok;
CREATE SCHEMA katalog;

CREATE TABLE stok.barang (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    sisa integer NOT NULL
);

INSERT INTO stok.barang (id, nama, sisa) VALUES
    (1, 'Kopi Gayo', 40);

CREATE TABLE katalog.barang (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(10,2) NOT NULL
);

INSERT INTO katalog.barang (id, nama, harga) VALUES
    (1, 'Kopi Gayo', 85000.00);

-- Nama yang sama, dua schema berbeda: nama yang membedakan
-- hanyalah prefiks skema di depannya.
SELECT 'stok' AS schema, id, nama, sisa::text AS nilai
FROM stok.barang
UNION ALL
SELECT 'katalog', id, nama, harga::text
FROM katalog.barang
ORDER BY schema, id;

-- Tanpa prefiks skema, nama tabel dicari lewat search_path.
SHOW search_path;

Output yang Dihasilkan:

text
DROP ROLE
CREATE ROLE
CREATE SCHEMA
CREATE SCHEMA
CREATE TABLE
INSERT 0 1
CREATE TABLE
INSERT 0 1
 schema  | id |   nama    |  nilai   
---------+----+-----------+----------
 katalog |  1 | Kopi Gayo | 85000.00
 stok    |  1 | Kopi Gayo | 40
(2 rows)

   search_path   
-----------------
 "$user", public
(1 row)

Query UNION ALL membaca kedua tabel barang sekaligus dan menunjukkan dua baris yang identik di kolom nama namun berbeda maknanya — satu sisa stok, satu harga katalog. Yang membedakan keduanya hanyalah prefiks schema di depan nama tabel. Baris SHOW search_path menampilkan urutan pencarian nama tanpa prefiks: "$user" (schema yang namanya sama dengan role) lalu public; nama barang tanpa prefiks akan dicari di sana-situ, dan bila tidak ketemu, PostgreSQL menolaknya dengan relation does not exist.

USAGE: Gerbang yang Harus Dibuka Sebelum Tabel Terlihat #

Hak USAGE pada schema adalah izin memasuki ruang nama itu. Tanpanya, seluruh objek di dalamnya — tabel, view, sequence, function — efektif tidak ada bagi role tersebut, seberapa pun banyaknya GRANT yang diberikan pada objeknya. File berikut sengaja membangun kontradiksi itu lalu menabrakkannya:

sql tanpa-usage.sql
-- Jebakan klasik: GRANT SELECT pada tabel TIDAK CUKUP.
-- Tanpa USAGE pada schema-nya, objek di dalamnya tetap tak terlihat.
GRANT SELECT ON stok.barang TO kasir_tmj;

-- Bukti kontradiksinya: privilege tabel dimiliki, hak schema tidak.
SELECT has_table_privilege('kasir_tmj', 'stok.barang', 'SELECT') AS hak_tabel,
       has_schema_privilege('kasir_tmj', 'stok', 'USAGE')        AS hak_schema;

SET ROLE kasir_tmj;

SELECT id, nama, sisa
FROM stok.barang
ORDER BY id;

Output yang Dihasilkan:

text
psql:tanpa-usage.sql:13: ERROR:  permission denied for schema stok
LINE 2: FROM stok.barang
             ^

Baris pemeriksaan catalog memperlihatkan kontradiksinya secara eksplisit: has_table_privilege menjawab t (hak SELECT pada stok.barang sungguh dimiliki), tetapi has_schema_privilege menjawab f (hak masuk schema stok tidak ada). Hasilnya, SELECT dari balik SET ROLE kasir_tmj ditolak bukan dengan pesan permission denied for table, melainkan permission denied for schema stok — penolakan terjadi di gerbang sebelum pemeriksaan tabel sempat berjalan. Kesalahpahaman umum “sudah di-GRANT kok masih ditolak” hampir selalu berakar di sini.

Penawalnya satu baris:

sql beri-usage.sql
-- Satu baris GRANT USAGE membuka gerbangnya: kini hak SELECT
-- pada tabel benar-benar bisa dipakai.
GRANT USAGE ON SCHEMA stok TO kasir_tmj;

SET ROLE kasir_tmj;

SELECT id, nama, sisa
FROM stok.barang
ORDER BY id;

RESET ROLE;

Output yang Dihasilkan:

text
GRANT
SET
 id |   nama    | sisa 
----+-----------+------
  1 | Kopi Gayo |   40
(1 row)

RESET

GRANT USAGE ON SCHEMA stok TO kasir_tmj membuka gerbangnya, dan query SELECT yang sama persis kini berhasil mengembalikan satu baris. Tidak ada perubahan pada grant tabel — yang berubah hanyalah izin melintasi schema. Pola lengkap untuk memberi akses baca tabel bagi role eksternal selalu dua baris: USAGE pada schema, SELECT pada tabel.

CREATE pada Schema dan Perubahan PostgreSQL 15 #

Hak kedua pada schema adalah CREATE: izin membuat objek baru di dalamnya. Inilah hak yang mengalami perubahan besar sejak PostgreSQL 15. Selama belasan tahun sebelumnya, setiap role bisa membuat tabel di schema public karena hak itu diberikan kepada PUBLIC secara bawaan — desain yang ramah bagi pemula namun berbahaya bagi server multi-pengguna, karena siapa pun bisa menyusupkan objek palsu ke ruang nama bersama. Sejak PostgreSQL 15, hak CREATE pada public dicabut dari PUBLIC:

-- Sejak PostgreSQL 15, schema public tidak lagi memberikan hak CREATE
-- kepada PUBLIC. Role non-pemilik kini ditolak saat membuat tabel di sana.
SET ROLE kasir_tmj;

CREATE TABLE public.catatan_umum (id integer);

Output yang Dihasilkan:

text
psql:public-tanpa-create.sql:5: ERROR:  permission denied for schema public
LINE 1: CREATE TABLE public.catatan_umum (id integer);
                     ^

kasir_tmj — role login biasa tanpa grant schema — ditolak dengan permission denied for schema public saat mencoba membuat tabel di sana. Kita sudah melihat bukti katalognya di chapter Privileges : ACL public kini hanya memuat =U/pg_database_owner untuk PUBLIC, tanpa huruf C.

Schema Bersama dan ALTER DEFAULT PRIVILEGES #

Tim kecil biasanya ingin satu schema kerja bersama: semua anggota boleh membuat objek, dan objek baru milik siapa pun langsung bisa dibaca rekan-rekannya. Kebutuhan pertama dipenuhi GRANT USAGE, CREATE ON SCHEMA; kebutuhan kedua — yang mustahil dipenuhi GRANT biasa karena tabelnya belum ada — ditangani ALTER DEFAULT PRIVILEGES, aturan yang diterapkan otomatis pada objek baru:

-- Pola schema bersama: USAGE + CREATE untuk anggota,
-- dan ALTER DEFAULT PRIVILEGES agar tabel baru otomatis terbaca.
CREATE SCHEMA bersama;

-- Hak pada schema: boleh masuk (USAGE) dan boleh membuat objek (CREATE).
GRANT USAGE, CREATE ON SCHEMA bersama TO kasir_tmj;

-- Aturan default untuk objek BARU yang dibuat postgres di schema ini:
-- SELECT otomatis diberikan kepada kasir_tmj tanpa GRANT manual.
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA bersama
    GRANT SELECT ON TABLES TO kasir_tmj;

-- kasir membuat tabel sendiri di schema bersama.
SET ROLE kasir_tmj;

CREATE TABLE bersama.catatan (
    id  integer PRIMARY KEY,
    isi text NOT NULL
);

INSERT INTO bersama.catatan (id, isi) VALUES
    (1, 'catatan dari kasir');

RESET ROLE;

-- postgres membuat tabel baru; default privileges langsung bekerja.
CREATE TABLE bersama.harga_pasar (
    id        integer PRIMARY KEY,
    komoditas text NOT NULL,
    harga     numeric(10,2) NOT NULL
);

INSERT INTO bersama.harga_pasar (id, komoditas, harga) VALUES
    (1, 'Beras', 13500.00);

SET ROLE kasir_tmj;

-- Tabel milik postgres bisa dibaca kasir tanpa satu pun GRANT manual:
-- hak SELECT-nya datang dari ALTER DEFAULT PRIVILEGES.
SELECT id, komoditas, harga
FROM bersama.harga_pasar
ORDER BY id;

-- Tabel buatan sendiri tentu saja bisa dibaca: kasir adalah pemiliknya.
SELECT id, isi
FROM bersama.catatan
ORDER BY id;

RESET ROLE;

-- Bukti dari catalog: grant SELECT atas nama kasir_tmj tertulis
-- pada tabel harga_pasar yang baru dibuat postgres.
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'bersama'
  AND table_name = 'harga_pasar'
ORDER BY grantee, privilege_type;

-- ACL schema bersama: kasir memegang UC (USAGE + CREATE) dari postgres.
SELECT nspname, nspacl
FROM pg_namespace
WHERE nspname = 'bersama';

Output yang Dihasilkan:

text
CREATE SCHEMA
GRANT
ALTER DEFAULT PRIVILEGES
SET
CREATE TABLE
INSERT 0 1
RESET
CREATE TABLE
INSERT 0 1
SET
 id | komoditas |  harga   
----+-----------+----------
  1 | Beras     | 13500.00
(1 row)

 id |        isi         
----+--------------------
  1 | catatan dari kasir
(1 row)

RESET
  grantee  | privilege_type 
-----------+----------------
 kasir_tmj | SELECT
 postgres  | DELETE
 postgres  | INSERT
 postgres  | REFERENCES
 postgres  | SELECT
 postgres  | TRIGGER
 postgres  | TRUNCATE
 postgres  | UPDATE
(8 rows)

 nspname |                    nspacl                    
---------+----------------------------------------------
 bersama | {postgres=UC/postgres,kasir_tmj=UC/postgres}
(1 row)

Mari kita bedah alurnya:

  1. GRANT USAGE, CREATE ON SCHEMA bersama TO kasir_tmj — dua hak schema sekaligus: boleh masuk dan boleh membuat objek. ACL di baris terakhir buktinya: kasir_tmj=UC/postgres.
  2. ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA bersama GRANT SELECT ON TABLES TO kasir_tmj — aturan untuk semua tabel yang akan dibuat oleh postgres di schema bersama: hak SELECT diberikan otomatis ke kasir_tmj. Klausa FOR ROLE menentukan pemilik objek mana yang aturannya berlaku — aturan ini tidak menyentuh tabel buatan role lain.
  3. CREATE TABLE bersama.catatan dari balik SET ROLE kasir_tmj — membuktikan hak CREATE bekerja; tabel itu dimiliki kasir.
  4. CREATE TABLE bersama.harga_pasar dijalankan postgres, lalu dibaca kasir tanpa satu pun GRANT manual — haknya datang dari aturan default. Catalog role_table_grants mengonfirmasi baris kasir_tmj | SELECT pada tabel itu.

Perhatikan kontras di dua SELECT terakhir file: harga_pasar terbaca karena default privileges dari pemiliknya, sedangkan catatan terbaca karena kepemilikan — kasir memang pemilik tabel buatannya sendiri. Dua jalan menuju hasil yang sama, dengan mekanisme yang sama sekali berbeda.

Catatan Keamanan search_path #

search_path adalah kenyamanan yang menyimpan jebakan. Query tanpa prefiks schema — SELECT * FROM barang — diselesaikan lewat schema pertama di search_path yang memuat objek bernama barang. Di tangan penyerang yang bisa membuat objek di schema awal pencarian, resolusi ini bisa diarahkan ke objek palsu: function jahat bernama upper() di schema yang lebih awal akan menang atas function bawaan di pg_catalog. Pertahanannya dua lapis:

  • Kualifikasi penuh di kode aplikasi — stok.barang, pg_catalog.upper() — mematikan resolusi ambigu sejak awal.
  • SET search_path yang eksplisit — misalnya ALTER ROLE aplikasi_kasir SET search_path = stok, pg_catalog — memastikan urutan pencarian hanya memuat schema yang Anda maksud, tanpa "$user" yang bisa berubah mengikuti nama role.

Kombinasi keduanya adalah standar untuk kode SECURITY DEFINER dan aplikasi produksi; kita akan bertemu lagi dengan tema ini saat membahas function keamanan di bagian stored programs.

Membersihkan Role Latihan #

Pembersihan akhir memakai pola biasa dengan satu tambahan yang perlu dicermati — DROP OWNED BY untuk kasir_tmj kini juga menjatuhkan tabel bersama.catatan karena kasir memilikinya:

sql bersihkan.sql
-- Pembersihan akhir: DROP OWNED BY melepas hak kasir di database ini
-- sekaligus menjatuhkan tabel miliknya, lalu role-nya dibuang.
DROP OWNED BY kasir_tmj;

DROP ROLE kasir_tmj;

SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname = 'kasir_tmj';

Output yang Dihasilkan:

text
DROP OWNED
DROP ROLE
 sisa_role_latihan 
-------------------
                 0
(1 row)

Setelah DROP OWNED BY, seluruh jejak kasir di database ini — hak schema, grant default, tabel miliknya — bersih sekaligus, dan DROP ROLE berjalan tanpa hambatan. Schema privileges kini terkuasai dari gerbang USAGE, izin CREATE, perubahan public PostgreSQL 15, sampai pola schema bersama. Namun semua yang kita bangun sejauh ini bekerja pada tingkat objek: yang boleh membaca tabel, boleh membaca seluruh tabel. Pertanyaan berikutnya berada di dalam tabel itu sendiri — baris mana yang boleh terlihat oleh siapa — dan itu wilayah Row Level Security .

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

beri-usage.sql

Unduh berkas
sql
-- Satu baris GRANT USAGE membuka gerbangnya: kini hak SELECT
-- pada tabel benar-benar bisa dipakai.
GRANT USAGE ON SCHEMA stok TO kasir_tmj;

SET ROLE kasir_tmj;

SELECT id, nama, sisa
FROM stok.barang
ORDER BY id;

RESET ROLE;

bersihkan.sql

Unduh berkas
sql
-- Pembersihan akhir: DROP OWNED BY melepas hak kasir di database ini
-- sekaligus menjatuhkan tabel miliknya, lalu role-nya dibuang.
DROP OWNED BY kasir_tmj;

DROP ROLE kasir_tmj;

SELECT count(*) AS sisa_role_latihan
FROM pg_roles
WHERE rolname = 'kasir_tmj';

buat-schema.sql

Unduh berkas
sql
-- Schema sebagai ruang nama: dua schema boleh memakai
-- nama tabel yang sama tanpa saling bertabrakan.
DROP ROLE IF EXISTS kasir_tmj;

CREATE ROLE kasir_tmj LOGIN;

CREATE SCHEMA stok;
CREATE SCHEMA katalog;

CREATE TABLE stok.barang (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    sisa integer NOT NULL
);

INSERT INTO stok.barang (id, nama, sisa) VALUES
    (1, 'Kopi Gayo', 40);

CREATE TABLE katalog.barang (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(10,2) NOT NULL
);

INSERT INTO katalog.barang (id, nama, harga) VALUES
    (1, 'Kopi Gayo', 85000.00);

-- Nama yang sama, dua schema berbeda: nama yang membedakan
-- hanyalah prefiks skema di depannya.
SELECT 'stok' AS schema, id, nama, sisa::text AS nilai
FROM stok.barang
UNION ALL
SELECT 'katalog', id, nama, harga::text
FROM katalog.barang
ORDER BY schema, id;

-- Tanpa prefiks skema, nama tabel dicari lewat search_path.
SHOW search_path;

public-tanpa-create.sql

Unduh berkas
sql
-- Sejak PostgreSQL 15, schema public tidak lagi memberikan hak CREATE
-- kepada PUBLIC. Role non-pemilik kini ditolak saat membuat tabel di sana.
SET ROLE kasir_tmj;

CREATE TABLE public.catatan_umum (id integer);

schema-bersama.sql

Unduh berkas
sql
-- Pola schema bersama: USAGE + CREATE untuk anggota,
-- dan ALTER DEFAULT PRIVILEGES agar tabel baru otomatis terbaca.
CREATE SCHEMA bersama;

-- Hak pada schema: boleh masuk (USAGE) dan boleh membuat objek (CREATE).
GRANT USAGE, CREATE ON SCHEMA bersama TO kasir_tmj;

-- Aturan default untuk objek BARU yang dibuat postgres di schema ini:
-- SELECT otomatis diberikan kepada kasir_tmj tanpa GRANT manual.
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA bersama
    GRANT SELECT ON TABLES TO kasir_tmj;

-- kasir membuat tabel sendiri di schema bersama.
SET ROLE kasir_tmj;

CREATE TABLE bersama.catatan (
    id  integer PRIMARY KEY,
    isi text NOT NULL
);

INSERT INTO bersama.catatan (id, isi) VALUES
    (1, 'catatan dari kasir');

RESET ROLE;

-- postgres membuat tabel baru; default privileges langsung bekerja.
CREATE TABLE bersama.harga_pasar (
    id        integer PRIMARY KEY,
    komoditas text NOT NULL,
    harga     numeric(10,2) NOT NULL
);

INSERT INTO bersama.harga_pasar (id, komoditas, harga) VALUES
    (1, 'Beras', 13500.00);

SET ROLE kasir_tmj;

-- Tabel milik postgres bisa dibaca kasir tanpa satu pun GRANT manual:
-- hak SELECT-nya datang dari ALTER DEFAULT PRIVILEGES.
SELECT id, komoditas, harga
FROM bersama.harga_pasar
ORDER BY id;

-- Tabel buatan sendiri tentu saja bisa dibaca: kasir adalah pemiliknya.
SELECT id, isi
FROM bersama.catatan
ORDER BY id;

RESET ROLE;

-- Bukti dari catalog: grant SELECT atas nama kasir_tmj tertulis
-- pada tabel harga_pasar yang baru dibuat postgres.
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'bersama'
  AND table_name = 'harga_pasar'
ORDER BY grantee, privilege_type;

-- ACL schema bersama: kasir memegang UC (USAGE + CREATE) dari postgres.
SELECT nspname, nspacl
FROM pg_namespace
WHERE nspname = 'bersama';

tanpa-usage.sql

Unduh berkas
sql
-- Jebakan klasik: GRANT SELECT pada tabel TIDAK CUKUP.
-- Tanpa USAGE pada schema-nya, objek di dalamnya tetap tak terlihat.
GRANT SELECT ON stok.barang TO kasir_tmj;

-- Bukti kontradiksinya: privilege tabel dimiliki, hak schema tidak.
SELECT has_table_privilege('kasir_tmj', 'stok.barang', 'SELECT') AS hak_tabel,
       has_schema_privilege('kasir_tmj', 'stok', 'USAGE')        AS hak_schema;

SET ROLE kasir_tmj;

SELECT id, nama, sisa
FROM stok.barang
ORDER BY id;