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.

Intermediate → Advanced240–330 minutesProduction capstone · ServiceHubPostgreSQL 18.4 baseline verified 2026-08-18; re-check current minor before productionCore PostgreSQL mandatory path; external poolers/HA control planes are optionalDisposable local cluster ports: primary 55480 · standby 55481 · restore 55482Administrative labs require PostgreSQL server utilities and a disposable local data directoryNo paid service required; destructive drills target only ch24_* lab resourcesLast reviewed: August 2026

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.

01

Create NOLOGIN owner/group roles and separate login identities without embedding secrets.

02

Build normalized ServiceHub tables and a time-partitioned event history with enforceable keys and constraints.

03

Implement tenant RLS from authenticated database identity rather than a user-settable custom GUC.

04

Verify safe search_path, ownership, grants, partition pruning, and TLS connection evidence.

05

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.

sql · roles and database ownership
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;
psql · set login secrets interactively
\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

sql · schemas and role-scoped namespace policy
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

sql · tenant, customer, and order tables
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

sql · partitioned 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

sql · partition catalog and pruning evidence
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.

sql · secure tenant-role mapping
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

sql · seed and policies
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

sql · admin-only identity simulation
-- 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

sql · inspect current connection encryption
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

sql · extension inventory and preconditions
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

sql · expand, backfill, validate, index
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;
sql · concurrent index must be top-level
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

sql · capstone security and schema assertions
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;
Deliberately wrong approach

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.

Production judgment

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

  1. Why is a tenant ID stored in a freely SET-able custom GUC insufficient against an untrusted SQL login?
  2. Why does the table owner separation matter for RLS?
  3. What does partition pruning prove that partition routing does not?
  4. Why is CREATE INDEX CONCURRENTLY not wrapped in BEGIN/COMMIT?
  5. 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.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.