Build the ServiceHub capstone schema with workload-derived indexes and partitions, least-privilege ownership, robust row-level security, safe namespace/TLS policy, extension governance, and versioned expand/contract migration checks.
Implement Schema, Indexes, Partitioning, Security, RLS, and Migration Automation
Build the ServiceHub capstone schema with workload-derived indexes and partitions, least-privilege ownership, robust row-level security, safe namespace/TLS policy, extension governance, and versioned expand/contract migration checks.
Learning outcomes
The architecture is now testable, so implementation must
preserve its security and data-integrity assumptions. ServiceHub
needs tenant isolation, predictable object ownership, safe
schema resolution, retention-friendly event partitions, indexes
that match declared access paths, and migrations that permit
old/new application versions to overlap. PostgreSQL row-level
security (RLS) is powerful, but table owners and roles with
BYPASSRLS normally bypass policies; a secure design
therefore separates login roles from object ownership.
Create NOLOGIN owner/group roles and separate login identities without embedding secrets.
Build normalized ServiceHub tables and a time-partitioned event history with enforceable keys and constraints.
Implement tenant RLS from authenticated database identity rather than a user-settable custom GUC.
Verify safe search_path, ownership, grants, partition pruning, and TLS connection evidence.
Apply an expand/backfill/validate/index/contract migration pattern with explicit lock-time and rollback boundaries.
1. Create ownership and login boundaries
The owner controls schema/table definitions.
The application group role carries DML
privileges but cannot log in. Tenant login roles inherit those
privileges while their authenticated identity remains visible as
session_user. A migrator may explicitly
SET ROLE to the owner; ordinary application
sessions cannot.
CREATE ROLE servicehub_cap_owner NOLOGIN;CREATE ROLE servicehub_cap_app NOLOGIN;CREATE ROLE servicehub_cap_migrator LOGIN NOINHERIT;CREATE ROLE servicehub_cap_tenant_a LOGIN IN ROLE servicehub_cap_app;CREATE ROLE servicehub_cap_tenant_b LOGIN IN ROLE servicehub_cap_app;CREATE ROLE servicehub_cap_repl LOGIN REPLICATION;GRANT servicehub_cap_owner TO servicehub_cap_migrator;ALTER DATABASE servicehub_capstone OWNER TO servicehub_cap_owner;REVOKE CREATE ON SCHEMA public FROM PUBLIC;REVOKE ALL ON DATABASE servicehub_capstone FROM PUBLIC;GRANT CONNECT ON DATABASE servicehub_capstoneTO servicehub_cap_app, servicehub_cap_migrator;
\password servicehub_cap_migrator\password servicehub_cap_tenant_a\password servicehub_cap_tenant_b\password servicehub_cap_repl
The course never writes real passwords into SQL, service files, or command history. In production, secret distribution belongs in the platform's secret-management boundary.
2. Create private namespaces and safe search paths
SET ROLE servicehub_cap_owner;CREATE SCHEMA app AUTHORIZATION servicehub_cap_owner;CREATE SCHEMA auth AUTHORIZATION servicehub_cap_owner;CREATE SCHEMA ops AUTHORIZATION servicehub_cap_owner;CREATE SCHEMA extensions AUTHORIZATION servicehub_cap_owner;ALTER DATABASE servicehub_capstoneSET search_path = pg_catalog, app;RESET ROLE;GRANT USAGE ON SCHEMA app, auth TO servicehub_cap_app;ALTER ROLE servicehub_cap_migrator IN DATABASE servicehub_capstoneSET search_path = pg_catalog, app;
The database-wide default puts pg_catalog first and app next, so every login—including tenant member roles—gets the same safe baseline unless a more specific role/session setting intentionally overrides it. Role settings are not inherited through membership, which is why setting search_path only on the NOLOGIN group role would be incomplete. No application role receives CREATE in a schema on its search path.
3. Build the relational core first
SET ROLE servicehub_cap_owner;CREATE TABLE app.tenants ( tenant_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_code text NOT NULL UNIQUE, display_name text NOT NULL, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT clock_timestamp());CREATE TABLE app.customers ( tenant_id bigint NOT NULL REFERENCES app.tenants(tenant_id), customer_id bigint GENERATED BY DEFAULT AS IDENTITY, external_ref text NOT NULL, display_name text NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (tenant_id, customer_id), UNIQUE (tenant_id, external_ref));CREATE TABLE app.work_orders ( tenant_id bigint NOT NULL, work_order_id bigint GENERATED BY DEFAULT AS IDENTITY, customer_id bigint NOT NULL, external_ref text NOT NULL, status text NOT NULL CHECK (status IN ('queued','assigned','in_progress','completed','cancelled')), scheduled_at timestamptz NOT NULL, amount numeric(12,2) NOT NULL CHECK (amount >= 0), note text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT clock_timestamp(), updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (tenant_id, work_order_id), UNIQUE (tenant_id, external_ref), FOREIGN KEY (tenant_id, customer_id) REFERENCES app.customers(tenant_id, customer_id));
Tenant ID participates in tenant-scoped keys, so a work order cannot accidentally reference another tenant's customer. This is relational integrity independent of RLS; security policies should not be asked to repair a broken model.
4. Partition only the append-heavy event history
CREATE TABLE app.work_order_events ( tenant_id bigint NOT NULL, event_id bigint GENERATED BY DEFAULT AS IDENTITY, work_order_id bigint NOT NULL, event_type text NOT NULL, occurred_at timestamptz NOT NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, PRIMARY KEY (tenant_id, event_id, occurred_at), FOREIGN KEY (tenant_id, work_order_id) REFERENCES app.work_orders(tenant_id, work_order_id)) PARTITION BY RANGE (occurred_at);CREATE TABLE app.work_order_events_2026_08PARTITION OF app.work_order_eventsFOR VALUES FROM ('2026-08-01 00:00+00') TO ('2026-09-01 00:00+00');CREATE TABLE app.work_order_events_2026_09PARTITION OF app.work_order_eventsFOR VALUES FROM ('2026-09-01 00:00+00') TO ('2026-10-01 00:00+00');CREATE TABLE app.work_order_events_2026_10PARTITION OF app.work_order_eventsFOR VALUES FROM ('2026-10-01 00:00+00') TO ('2026-11-01 00:00+00');CREATE INDEX work_orders_queue_idxON app.work_orders (tenant_id, status, scheduled_at);CREATE INDEX work_order_events_lookup_idxON app.work_order_events (tenant_id, work_order_id, occurred_at DESC);RESET ROLE;
There is intentionally no DEFAULT partition. If the next monthly partition has not been created, an out-of-range insert fails loudly instead of silently defeating retention policy. Production automation should create/verify future partitions before rollover.
5. Make partition routing and pruning observable
SELECT inhparent::regclass AS parent, inhrelid::regclass AS partitionFROM pg_inheritsWHERE inhparent = 'app.work_order_events'::regclassORDER BY inhrelid::regclass::text;EXPLAIN (COSTS OFF)SELECT tenant_id, work_order_id, event_type, occurred_atFROM app.work_order_eventsWHERE occurred_at >= TIMESTAMPTZ '2026-09-10 00:00+00' AND occurred_at < TIMESTAMPTZ '2026-09-11 00:00+00';
Expected: the plan references only the September partition when the time predicate is known. Routing sends inserts to a matching partition; pruning removes impossible partitions from a query plan/execution. They are related but distinct mechanisms.
6. Build tenant identity from session_user, not a forgeable GUC
A custom setting such as SET app.tenant_id='42' is
useful application context, but any database login permitted to
set it can forge another tenant ID. For this lab, authenticated
login roles map to tenants in a table that application users
cannot modify. A narrowly written
SECURITY DEFINER function reads that map with a
fixed search path.
SET ROLE servicehub_cap_owner;CREATE TABLE auth.tenant_role_map ( role_name name PRIMARY KEY, tenant_id bigint NOT NULL UNIQUE);CREATE OR REPLACE FUNCTION auth.current_tenant_id()RETURNS bigintLANGUAGE sqlSTABLESECURITY DEFINERSET search_path = pg_catalog, authAS $$ SELECT m.tenant_id FROM auth.tenant_role_map AS m WHERE m.role_name = session_user$$;REVOKE ALL ON FUNCTION auth.current_tenant_id() FROM PUBLIC;GRANT EXECUTE ON FUNCTION auth.current_tenant_id() TO servicehub_cap_app;RESET ROLE;
The function owner must protect both auth and the
mapping table from untrusted CREATE/UPDATE access. In large SaaS
systems, one database role per tenant may not be operationally
desirable; the production identity propagation design can
differ, but it must not trust a value that an untrusted SQL
principal can freely forge.
7. Seed two tenants and enable RLS
SET ROLE servicehub_cap_owner;INSERT INTO app.tenants(tenant_code,display_name)VALUES ('tenant-a','Tenant A'),('tenant-b','Tenant B');INSERT INTO auth.tenant_role_map(role_name,tenant_id)SELECT 'servicehub_cap_tenant_a', tenant_idFROM app.tenants WHERE tenant_code='tenant-a'UNION ALLSELECT 'servicehub_cap_tenant_b', tenant_idFROM app.tenants WHERE tenant_code='tenant-b';ALTER TABLE app.tenants ENABLE ROW LEVEL SECURITY;ALTER TABLE app.customers ENABLE ROW LEVEL SECURITY;ALTER TABLE app.work_orders ENABLE ROW LEVEL SECURITY;ALTER TABLE app.work_order_events ENABLE ROW LEVEL SECURITY;CREATE POLICY tenant_rows_tenants ON app.tenantsUSING (tenant_id = auth.current_tenant_id());CREATE POLICY tenant_rows_customers ON app.customersUSING (tenant_id = auth.current_tenant_id())WITH CHECK (tenant_id = auth.current_tenant_id());CREATE POLICY tenant_rows_orders ON app.work_ordersUSING (tenant_id = auth.current_tenant_id())WITH CHECK (tenant_id = auth.current_tenant_id());CREATE POLICY tenant_rows_events ON app.work_order_eventsUSING (tenant_id = auth.current_tenant_id())WITH CHECK (tenant_id = auth.current_tenant_id());GRANT SELECT ON app.tenants TO servicehub_cap_app;GRANT SELECT,INSERT,UPDATE,DELETE ON app.customers,app.work_orders,app.work_order_eventsTO servicehub_cap_app;GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA app TO servicehub_cap_app;ALTER DEFAULT PRIVILEGES FOR ROLE servicehub_cap_owner IN SCHEMA appGRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO servicehub_cap_app;ALTER DEFAULT PRIVILEGES FOR ROLE servicehub_cap_owner IN SCHEMA appGRANT USAGE,SELECT ON SEQUENCES TO servicehub_cap_app;RESET ROLE;
Owners normally bypass RLS, and superusers/roles with
BYPASSRLS always can. That is why the owner is
NOLOGIN and the application uses separate identities.
8. Prove cross-tenant isolation as the server administrator
-- Run as the local lab superuser.SET SESSION AUTHORIZATION servicehub_cap_tenant_a;INSERT INTO app.customers(tenant_id,external_ref,display_name)SELECT auth.current_tenant_id(),'A-001','Alice';SELECT tenant_id,external_ref,display_nameFROM app.customers;-- Deliberately try to insert Tenant B's ID.INSERT INTO app.customers(tenant_id,external_ref,display_name)SELECT tenant_id,'FORGED','Should fail'FROM app.tenantsWHERE tenant_code='tenant-b';RESET SESSION AUTHORIZATION;
Expected: Tenant A sees only its own rows. The forged insert
cannot obtain Tenant B through the RLS-filtered tenants table;
if a raw Tenant B ID is supplied, the
WITH CHECK policy rejects it. This is a
database-level isolation test, not proof of complete application
authorization.
9. TLS evidence: server encryption and client verification are different
SHOW ssl;SELECT pid, ssl, version, cipher, bits, client_dn, issuer_dnFROM pg_stat_sslWHERE pid = pg_backend_pid();
ssl=on means the server can accept TLS;
pg_stat_ssl.ssl=true proves this connection is
encrypted. Neither alone proves the client verified the server
identity. A production libpq connection that requires hostname
verification uses sslmode=verify-full with an
appropriate trust root. A production HBA might use a rule such
as
hostssl servicehub_capstone +servicehub_cap_app 10.20.0.0/16
scram-sha-256; certificates, addresses, and client policy are
deployment-specific.
pg_hba.conf is evaluated first-match, so place narrow hostssl/SCRAM rules before broader rules and inspect pg_hba_file_rules after edits. Server-side TLS plus SCRAM authenticates/encrypts according to the chosen rule; client-side sslmode=verify-full is the control that verifies the server certificate chain and requested host name.
10. Govern extensions before CREATE EXTENSION
SELECT name,default_version,installed_version,commentFROM pg_available_extensionsORDER BY name;SELECT name,version,installed,superuser,trusted, relocatable,schema,requiresFROM pg_available_extension_versionsORDER BY name,version;SELECT extname,extversion,extnamespace::regnamespaceFROM pg_extensionORDER BY extname;
The capstone requires no third-party extension. If
pg_stat_statements is available and already
preloaded according to Lesson 21, it is useful optional
evidence. An extension decision must record package availability
for PostgreSQL 18.4, upgrade compatibility, privilege/trust
requirements, preload/restart needs, backup behavior, and a
removal/fallback plan.
11. Expand/contract migration: add priority without breaking old code
SET ROLE servicehub_cap_owner;SET lock_timeout = '2s';ALTER TABLE app.work_ordersADD COLUMN priority smallint;ALTER TABLE app.work_ordersADD CONSTRAINT work_orders_priority_validCHECK (priority BETWEEN 1 AND 5) NOT VALID;RESET lock_timeout;-- Bounded, idempotent batches; repeat until zero rows returned.WITH batch AS ( SELECT ctid FROM app.work_orders WHERE priority IS NULL ORDER BY tenant_id, work_order_id LIMIT 5000 FOR UPDATE SKIP LOCKED)UPDATE app.work_orders AS wSET priority = 3FROM batchWHERE w.ctid = batch.ctid;ALTER TABLE app.work_ordersVALIDATE CONSTRAINT work_orders_priority_valid;RESET ROLE;
SET ROLE servicehub_cap_owner;SET lock_timeout = '2s';CREATE INDEX CONCURRENTLY work_orders_priority_schedule_idxON app.work_orders (tenant_id, priority, scheduled_at)WHERE status IN ('queued','assigned','in_progress');RESET lock_timeout;RESET ROLE;
CREATE INDEX CONCURRENTLY cannot run inside a
transaction block. A failed concurrent build can leave an
invalid index, so deployment automation must inspect
pg_index.indisvalid and repair with a concurrent
drop/rebuild or suitable concurrent reindex.
12. Metadata/security acceptance checks
SELECT n.nspname, r.rolname AS ownerFROM pg_namespace AS nJOIN pg_roles AS r ON r.oid=n.nspownerWHERE n.nspname IN ('app','auth','ops','extensions')ORDER BY n.nspname;SELECT relname, relrowsecurity, relforcerowsecurityFROM pg_classWHERE relnamespace='app'::regnamespace AND relkind IN ('r','p')ORDER BY relname;SELECT schemaname, tablename, policyname, roles, cmdFROM pg_policiesWHERE schemaname='app'ORDER BY tablename,policyname;SELECT indexrelid::regclass, indisvalid, indisreadyFROM pg_indexWHERE indrelid='app.work_orders'::regclassORDER BY indexrelid::regclass::text;
Making servicehub_cap_app the table owner “so migrations are easy” defeats the RLS boundary because table owners normally bypass row security and also grants broad DDL power. Keep ownership NOLOGIN and route schema changes through the migrator → SET ROLE owner path.
Security is an object graph: authentication, session identity, role membership, ownership, schema writability, function search paths, RLS, TLS verification, and migration authority must agree. Lesson 3 now tests whether the implemented model meets latency/maintenance capacity without tuning by guesswork.
Check your understanding
- Why is a tenant ID stored in a freely SET-able custom GUC insufficient against an untrusted SQL login?
- Why does the table owner separation matter for RLS?
- What does partition pruning prove that partition routing does not?
- Why is CREATE INDEX CONCURRENTLY not wrapped in BEGIN/COMMIT?
- What does pg_stat_ssl prove—and what does it not prove?
Review the answers
A login could forge a custom GUC unless trusted identity propagation prevents it. Owners normally bypass RLS, so application login and ownership must be separated. Routing chooses an insert partition; pruning proves unrelated partitions are avoided for a query. Concurrent index creation is prohibited inside an explicit transaction block. pg_stat_ssl proves whether the current backend uses TLS and its negotiated properties; it does not prove the client verified the server hostname/CA policy.
Authoritative references
Use current upstream PostgreSQL documentation and release/support pages as the source of truth for version-, security-, topology-, and recovery-sensitive behavior.