Enam chapter sebelumnya memakai view sebagai alat kenyamanan: menyimpan query panjang, menyederhanakan join, membekukan laporan berat. Padahal separuh alasan view hadir di sistem produksi adalah keamanan. Aplikasi kasir perlu membaca nama pelanggan tanpa pernah menyentuh kolom email dan telepon; setiap penjual boleh melihat transaksinya sendiri tetapi tidak milik rekan lain; user database aplikasi seharusnya tidak punya akses langsung ke tabel apa pun — hanya ke jendela yang kita bukakan untuknya. View adalah mekanisme jendela itu, dan PostgreSQL mengencangkan bautnya dengan sistem privilege: GRANT diberikan pada view, bukan pada tabel di baliknya. Chapter ini merangkai ketiganya — kolom tersembunyi, baris tersaring, hak akses minimal — lalu menutup dengan dua opusi pengaman lanjutan: security_barrier dan security_invoker.

Menyembunyikan Kolom Sensitif #

Bahan latihannya dua tabel kecil Toko Maju Jaya: pelanggan yang memuat kolom sensitif (email, telepon) dan penjualan yang nanti menjadi sumber view pribadi per penjual. File pembuka juga membuang role latihan kasir_latihan bila tersisa dari eksekusi sebelumnya — role adalah objek level cluster yang dipakai bersama seluruh database, sehingga pembersihannya tidak bisa digaransi oleh database latihan yang selalu dibuat ulang:

sql buat-tabel.sql
-- Bahan latihan chapter View dan Keamanan Data.
-- Role adalah objek cluster-wide (dipakai bersama seluruh database),
-- jadi dibuang dulu bila tersisa dari latihan sebelumnya.
DROP ROLE IF EXISTS kasir_latihan;

-- Tabel pelanggan: kolom email dan telepon bersifat sensitif.
CREATE TABLE pelanggan (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kota     text NOT NULL,
    email    text NOT NULL UNIQUE,
    telepon  text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, email, telepon) VALUES
    (1, 'Budi Santoso', 'Jakarta',    '[email protected]', '0812-5000-1001'),
    (2, 'Siti Rahayu',  'Bandung',    '[email protected]', '0813-6000-2002'),
    (3, 'Andi Wijaya',  'Surabaya',   '[email protected]', '0814-7000-3003'),
    (4, 'Dewi Lestari', 'Yogyakarta', '[email protected]', '0815-8000-4004'),
    (5, 'Rina Marlina', 'Medan',      '[email protected]', '0816-9000-5005');

-- Tabel penjualan: sumber view pribadi per penjual.
CREATE TABLE penjualan (
    id      integer PRIMARY KEY,
    tanggal date NOT NULL,
    penjual text NOT NULL,
    produk  text NOT NULL,
    jumlah  integer NOT NULL,
    harga   numeric(10,2) NOT NULL
);

INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
    (1, DATE '2026-01-05', 'Budi', 'Kopi Gayo',  10, 85000.00),
    (2, DATE '2026-01-08', 'Siti', 'Teh Melati', 25, 35000.00),
    (3, DATE '2026-01-12', 'Andi', 'Gula Merah', 40, 15000.00),
    (4, DATE '2026-01-20', 'Dewi', 'Kopi Gayo',  12, 85000.00),
    (5, DATE '2026-01-22', 'Siti', 'Teh Hijau',  15, 40000.00),
    (6, DATE '2026-01-25', 'Siti', 'Teh Melati', 22, 35000.00);

SELECT id, nama, kota, email, telepon
FROM pelanggan
ORDER BY id;

Output yang Dihasilkan:

text
DROP ROLE
CREATE TABLE
INSERT 0 5
CREATE TABLE
INSERT 0 6
 id |     nama     |    kota    |      email       |    telepon     
----+--------------+------------+------------------+----------------
  1 | Budi Santoso | Jakarta    | [email protected] | 0812-5000-1001
  2 | Siti Rahayu  | Bandung    | [email protected] | 0813-6000-2002
  3 | Andi Wijaya  | Surabaya   | [email protected] | 0814-7000-3003
  4 | Dewi Lestari | Yogyakarta | [email protected] | 0815-8000-4004
  5 | Rina Marlina | Medan      | [email protected] | 0816-9000-5005
(5 rows)

View untuk konsumsi umum cukup memuat kolom yang boleh dilihat. Daftar SELECT yang pendek itulah seluruh dunia pembaca view:

-- View untuk publik: hanya kolom yang boleh dilihat umum.
-- Kolom email dan telepon tidak ikut -- bagi pembaca view,
-- kolom itu bahkan tidak pernah ada.
CREATE VIEW pelanggan_publik AS
SELECT id, nama, kota
FROM pelanggan;

SELECT id, nama, kota
FROM pelanggan_publik
ORDER BY id;

-- Bukti dari catalog: daftar kolom view hanya berisi tiga nama.
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'pelanggan_publik'
ORDER BY ordinal_position;

Output yang Dihasilkan:

text
CREATE VIEW
 id |     nama     |    kota    
----+--------------+------------
  1 | Budi Santoso | Jakarta
  2 | Siti Rahayu  | Bandung
  3 | Andi Wijaya  | Surabaya
  4 | Dewi Lestari | Yogyakarta
  5 | Rina Marlina | Medan
(5 rows)

 column_name 
-------------
 id
 nama
 kota
(3 rows)

Dua pembuktian di file itu saling menguatkan. Query pertama menampilkan lima pelanggan dengan tiga kolom; query kedua menanyakan catalog (information_schema.columns) dan mendapat jawaban yang sama: view pelanggan_publik hanya memiliki kolom id, nama, kota. Bagi pembaca view, email dan telepon bukan kolom yang dikosongkan atau disamarkan — kolom itu tidak ada. Mencoba menuliskannya di query akan ditolak sebagai kolom yang tidak dikenal, bukan sebagai nilai yang disembunyikan.

Menyaring Baris dengan View Pribadi #

Penyaringan tingkat kedua bekerja pada baris, bukan kolom: klausa WHERE di definisi view membatasi baris mana yang bisa terlihat. Pola klasiknya adalah view pribadi — satu view per penjual yang hanya memuat transaksi miliknya:

-- View pribadi Siti: klausa WHERE membatasi baris yang terlihat.
-- Pola yang sama bisa dibuat untuk Budi, Andi, dan Dewi.
CREATE VIEW penjualan_siti AS
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
WHERE penjual = 'Siti';

SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan_siti
ORDER BY id;

Output yang Dihasilkan:

text
CREATE VIEW
 id |  tanggal   | penjual |   produk   | jumlah |  harga   
----+------------+---------+------------+--------+----------
  2 | 2026-01-08 | Siti    | Teh Melati |     25 | 35000.00
  5 | 2026-01-22 | Siti    | Teh Hijau  |     15 | 40000.00
  6 | 2026-01-25 | Siti    | Teh Melati |     22 | 35000.00
(3 rows)

Enam baris di tabel penjualan terpangkas menjadi tiga di view: hanya transaksi id 2, 5, dan 6 yang bernilai penjual = 'Siti'. Dengan enam penjual, kita cukup membuat enam view serupa dan memberikan masing-masing kepada pemiliknya. Satu batasan pola ini perlu diakui jujur: nilai 'Siti' tertanam di definisi — view ini milik Siti selamanya, bukan “milik siapa pun yang login”. Untuk penyaringan yang mengikuti identitas pengguna secara dinamis, sistem nyata umumnya melangkah lebih jauh ke fitur Row Level Security yang memeriksa identitas sesi pada setiap baris.

Memberi Akses Tanpa Menyentuh Tabel Dasar #

Dua view di atas masih bisa dibaca siapa saja yang punya akses penuh. Sekarang kita bangun sisi keamanan yang sesungguhnya: sebuah role database untuk aplikasi kasir, hak SELECT yang diberikan hanya pada view, lalu pembuktian bahwa role itu bekerja tanpa satu pun hak pada tabel dasar:

sql beri-akses.sql
-- Membuat role latihan untuk aplikasi kasir, lalu memberinya
-- hak SELECT pada view -- bukan pada tabel dasarnya.
CREATE ROLE kasir_latihan LOGIN PASSWORD 'YOUR_PASSWORD';

GRANT SELECT ON pelanggan_publik TO kasir_latihan;
GRANT SELECT ON penjualan_siti TO kasir_latihan;

-- SET ROLE menjalankan perintah berikutnya seolah dilakukan
-- oleh kasir_latihan, tanpa perlu koneksi baru.
SET ROLE kasir_latihan;

SELECT id, nama, kota
FROM pelanggan_publik
ORDER BY id;

SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan_siti
ORDER BY id;

RESET ROLE;

Output yang Dihasilkan:

text
CREATE ROLE
GRANT
GRANT
SET
 id |     nama     |    kota    
----+--------------+------------
  1 | Budi Santoso | Jakarta
  2 | Siti Rahayu  | Bandung
  3 | Andi Wijaya  | Surabaya
  4 | Dewi Lestari | Yogyakarta
  5 | Rina Marlina | Medan
(5 rows)

 id |  tanggal   | penjual |   produk   | jumlah |  harga   
----+------------+---------+------------+--------+----------
  2 | 2026-01-08 | Siti    | Teh Melati |     25 | 35000.00
  5 | 2026-01-22 | Siti    | Teh Hijau  |     15 | 40000.00
  6 | 2026-01-25 | Siti    | Teh Melati |     22 | 35000.00
(3 rows)

RESET

Mari kita bedah rantai perintahnya:

  1. CREATE ROLE kasir_latihan LOGIN PASSWORD 'YOUR_PASSWORD' — membuat role dengan hak masuk (LOGIN); sandi di sini hanya penampung latihan. Di server latihan kita bekerja sebagai superuser, jadi cukup berpindah kulit dengan SET ROLE alih-alih membuka koneksi baru.
  2. GRANT SELECT ON ... TO kasir_latihan — dua grant pada dua view; tidak ada satu baris pun yang memberikan hak atas pelanggan atau penjualan.
  3. SET ROLE kasir_latihan — sejak titik ini, pemeriksaan hak dilakukan menurut role kasir. Kedua SELECT pada view berhasil.
  4. RESET ROLE — kembali ke identitas superuser untuk perintah-perintah selanjutnya.

Hak yang Tidak Diberikan Tidak Bocor #

Sisi gelap pola ini justru bagian yang paling meyakinkan: role kasir yang sama kini mencoba membaca tabel pelanggan secara langsung:

-- Masih memakai role kasir_latihan yang belum punya hak apa pun
-- atas tabel dasar. SELECT langsung ke tabel ditolak.
SET ROLE kasir_latihan;

SELECT id, nama, kota, email, telepon
FROM pelanggan
ORDER BY id;

Output yang Dihasilkan:

text
psql:akses-tabel-error.sql:7: ERROR:  permission denied for table pelanggan

PostgreSQL menolak mentah-mentah: permission denied for table pelanggan. Tabel penjualan nasibnya sama — tidak ada grant untuknya, sehingga jalur menuju data mentah tertutup seluruhnya. Gabungan kedua pembuktian inilah inti chapter: email dan telepon pelanggan tidak bisa dibaca kasir dari arah mana pun, karena kolom itu tidak ada di view yang dikasihan, dan tabel yang memuatnya tidak dikasihan. Mengetahui nama tabel dan kolom tidak menolong bila privilege-nya memang tidak diberikan.

Security Barrier dan Eksekusi sebagai Pemanggil #

Dua opsi lanjutan menutup celah-celah yang lebih halus. Yang pertama, security_barrier, menjawab kekhawatiran leaky view: penulis query bisa memanggil fungsi buatan sendiri di klausa WHERE, dan tanpa pengaman optimizer berpeluang menjalankan fungsi itu pada baris yang seharusnya sudah disaring oleh view — penulis fungsi yang jahat bisa membocorkan baris yang tak patut ia lihat. Opsi ini memaksa penyaring milik view dieksekusi lebih dulu sebelum fungsi luar menyentuh barisnya:

-- security_barrier memaksa filter milik view diterapkan lebih dulu
-- sebelum fungsi dari luar bisa menyentuh barisnya.
CREATE VIEW penjualan_siti_ketat WITH (security_barrier = true) AS
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
WHERE penjual = 'Siti';

SELECT relname, reloptions
FROM pg_class
WHERE relname IN ('penjualan_siti', 'penjualan_siti_ketat')
ORDER BY relname;

Output yang Dihasilkan:

text
CREATE VIEW
       relname        |       reloptions        
----------------------+-------------------------
 penjualan_siti       | 
 penjualan_siti_ketat | {security_barrier=true}
(2 rows)

Catalog membedakan keduanya dengan jelas: penjualan_siti tanpa opsi, penjualan_siti_ketat membawa reloptions {security_barrier=true}. Harganya ada sedikit — pembatasan optimasi membuat beberapa query terencana kurang efisien — sehingga opsi ini layak dipakai ketika penyaringan barisnya sungguh-sungguh rahasia dan pengguna view tidak semua bisa dipercaya. Opsi kedua, security_invoker (tersedia sejak PostgreSQL 15), membalik perilaku bawaan: pemeriksaan hak tabel dasar dilakukan terhadap pembaca view, bukan pemiliknya — berguna saat view dibagikan sebagai kemudahan baca, tetapi tiap pembaca tetap harus membawa hak aksesnya sendiri.

Membersihkan Role Latihan #

File penutup membalikkan semua yang dibangun: DROP OWNED BY melepas seluruh hak yang dipegang role di database ini, kemudian DROP ROLE membuang role-nya. Urutan ini wajib — saya menguji DROP ROLE tanpa DROP OWNED BY sebelumnya dan PostgreSQL menolaknya dengan pesan role "kasir_latihan" cannot be dropped because some objects depend on it, merinci privilege yang masih menempel pada kedua view:

sql bersihkan.sql
-- Pembersihan role latihan agar file bisa dijalankan ulang.
-- DROP OWNED BY melepas semua hak yang diberikan kepada role ini
-- di database saat ini; tanpa itu, DROP ROLE gagal karena masih
-- ada privilege yang bergantung pada role.
DROP OWNED BY kasir_latihan;

DROP ROLE kasir_latihan;

Output yang Dihasilkan:

text
DROP OWNED
DROP ROLE

Kebiasaan membersihkan role latihan ini penting untuk dijadikan refleks: karena role hidup di level cluster, role yang terlupakan tetap terdaftar dan bisa namanya bertabrakan dengan latihan Anda berikutnya. Dengan DROP ROLE IF EXISTS di file pembuka dan pembersihan tuntas di file penutup, seluruh chapter ini bisa dijalankan ulang berkali-kali tanpa sisa.

Kita kini menutup section View dan Materialized View dengan keahlian membaca data secara bertanggung jawab: menyederhanakan lewat view, mempercepat lewat materialized view, dan mengamankan lewat privilege berlapis. Sisi aplikasi database yang belum tersentuh adalah tulisan yang andal — bagaimana PostgreSQL menjaga serangkaian perubahan tetap utuh saat koneksi putus di tengah jalan. Itulah wilayah section berikutnya, dimulai dari Konsep Transaksi .

Referensi #

Berkas Sumber

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

akses-tabel-error.sql

Unduh berkas
sql
-- Masih memakai role kasir_latihan yang belum punya hak apa pun
-- atas tabel dasar. SELECT langsung ke tabel ditolak.
SET ROLE kasir_latihan;

SELECT id, nama, kota, email, telepon
FROM pelanggan
ORDER BY id;

beri-akses.sql

Unduh berkas
sql
-- Membuat role latihan untuk aplikasi kasir, lalu memberinya
-- hak SELECT pada view -- bukan pada tabel dasarnya.
CREATE ROLE kasir_latihan LOGIN PASSWORD 'YOUR_PASSWORD';

GRANT SELECT ON pelanggan_publik TO kasir_latihan;
GRANT SELECT ON penjualan_siti TO kasir_latihan;

-- SET ROLE menjalankan perintah berikutnya seolah dilakukan
-- oleh kasir_latihan, tanpa perlu koneksi baru.
SET ROLE kasir_latihan;

SELECT id, nama, kota
FROM pelanggan_publik
ORDER BY id;

SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan_siti
ORDER BY id;

RESET ROLE;

bersihkan.sql

Unduh berkas
sql
-- Pembersihan role latihan agar file bisa dijalankan ulang.
-- DROP OWNED BY melepas semua hak yang diberikan kepada role ini
-- di database saat ini; tanpa itu, DROP ROLE gagal karena masih
-- ada privilege yang bergantung pada role.
DROP OWNED BY kasir_latihan;

DROP ROLE kasir_latihan;

buat-tabel.sql

Unduh berkas
sql
-- Bahan latihan chapter View dan Keamanan Data.
-- Role adalah objek cluster-wide (dipakai bersama seluruh database),
-- jadi dibuang dulu bila tersisa dari latihan sebelumnya.
DROP ROLE IF EXISTS kasir_latihan;

-- Tabel pelanggan: kolom email dan telepon bersifat sensitif.
CREATE TABLE pelanggan (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    kota     text NOT NULL,
    email    text NOT NULL UNIQUE,
    telepon  text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, email, telepon) VALUES
    (1, 'Budi Santoso', 'Jakarta',    '[email protected]', '0812-5000-1001'),
    (2, 'Siti Rahayu',  'Bandung',    '[email protected]', '0813-6000-2002'),
    (3, 'Andi Wijaya',  'Surabaya',   '[email protected]', '0814-7000-3003'),
    (4, 'Dewi Lestari', 'Yogyakarta', '[email protected]', '0815-8000-4004'),
    (5, 'Rina Marlina', 'Medan',      '[email protected]', '0816-9000-5005');

-- Tabel penjualan: sumber view pribadi per penjual.
CREATE TABLE penjualan (
    id      integer PRIMARY KEY,
    tanggal date NOT NULL,
    penjual text NOT NULL,
    produk  text NOT NULL,
    jumlah  integer NOT NULL,
    harga   numeric(10,2) NOT NULL
);

INSERT INTO penjualan (id, tanggal, penjual, produk, jumlah, harga) VALUES
    (1, DATE '2026-01-05', 'Budi', 'Kopi Gayo',  10, 85000.00),
    (2, DATE '2026-01-08', 'Siti', 'Teh Melati', 25, 35000.00),
    (3, DATE '2026-01-12', 'Andi', 'Gula Merah', 40, 15000.00),
    (4, DATE '2026-01-20', 'Dewi', 'Kopi Gayo',  12, 85000.00),
    (5, DATE '2026-01-22', 'Siti', 'Teh Hijau',  15, 40000.00),
    (6, DATE '2026-01-25', 'Siti', 'Teh Melati', 22, 35000.00);

SELECT id, nama, kota, email, telepon
FROM pelanggan
ORDER BY id;

filter-baris.sql

Unduh berkas
sql
-- View pribadi Siti: klausa WHERE membatasi baris yang terlihat.
-- Pola yang sama bisa dibuat untuk Budi, Andi, dan Dewi.
CREATE VIEW penjualan_siti AS
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
WHERE penjual = 'Siti';

SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan_siti
ORDER BY id;

security-barrier.sql

Unduh berkas
sql
-- security_barrier memaksa filter milik view diterapkan lebih dulu
-- sebelum fungsi dari luar bisa menyentuh barisnya.
CREATE VIEW penjualan_siti_ketat WITH (security_barrier = true) AS
SELECT id, tanggal, penjual, produk, jumlah, harga
FROM penjualan
WHERE penjual = 'Siti';

SELECT relname, reloptions
FROM pg_class
WHERE relname IN ('penjualan_siti', 'penjualan_siti_ketat')
ORDER BY relname;

sembunyikan-kolom.sql

Unduh berkas
sql
-- View untuk publik: hanya kolom yang boleh dilihat umum.
-- Kolom email dan telepon tidak ikut -- bagi pembaca view,
-- kolom itu bahkan tidak pernah ada.
CREATE VIEW pelanggan_publik AS
SELECT id, nama, kota
FROM pelanggan;

SELECT id, nama, kota
FROM pelanggan_publik
ORDER BY id;

-- Bukti dari catalog: daftar kolom view hanya berisi tiga nama.
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'pelanggan_publik'
ORDER BY ordinal_position;