This lesson keeps the promises the course has been making since module 5. It deals with two things that look different and are the same: who decides what gets executed and who can see what. The first half is SQL injection, the oldest and most expensive security flaw in database-backed applications, explained from the side that matters: how it's prevented. The second is PostgreSQL's permission and role system, including row-level security, with a concrete design for GreenStore.
A warning from the outset: this is a defensive lesson. The attack examples you'll see are minimal and harmless —a condition that returns too much— and they're here only so you understand why the vulnerable approach fails. The weight of the lesson is on the defences. And a warning that's meant seriously: before exposing a system with real data to the Internet, commission a review by a security professional and check the handling of personal data with your organization's legal or data protection officer. What follows is the minimum you should know, not a substitute for that review.
Contents
- What an SQL injection is
- Why it happens: code and data in the same string
- The defence: parameterized queries
- What is not a sufficient defence
- The special case: dynamic identifiers
- Defence in depth
- Permissions and roles in PostgreSQL
- A role design for GreenStore
- Row Level Security
SECURITY DEFINERand thesearch_path- Personal data
- Security checklist
- Common Mistakes and Tips
- Exercises
- Conclusion
- What an SQL injection is
The shop's search box takes a piece of text and looks for products. Written the worst possible way —by concatenating— it looks like this:
# ⚠️ VULNERABLE. Never write this.
term = request.args["q"]
sql = "SELECT id, name, price FROM products WHERE active AND name ILIKE '%" + term + "%'"
cursor.execute(sql)With the normal input oil, what reaches the server is SELECT id, name, price FROM products WHERE active AND name ILIKE '%oil%';, which is correct and returns what you'd expect:
| id | name | price |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 |
| 8 | Almond body oil 200 ml | 14.25 |
Two products. Now the input ' OR '1'='1 — a piece of text that's perfectly typeable in any search box. Concatenated in, the query that runs is:
It returns 19 rows: the entire active catalogue. The user's quote closed the string the programmer had opened, and what came after stopped being search text and became query structure: an OR nobody wrote. With the variant ' OR '1'='1' --, the double dash comments out the rest of the line, including the dangling %':
20 rows. One more: the spirulina capsules show up too, the discontinued product (active = FALSE) the shop should never display. It's a tiny, harmless example, and that's exactly why it's useful: the attacker hasn't done anything strange; they've typed text into a text box, and they've made the business filter stop applying. If instead of a public catalogue the query had been the one for "my orders" or the one for "employees", the result would have been the same mechanism over data that does matter.
And a less obvious but equally serious consequence: the input O'Connor —a perfectly ordinary surname— breaks the query with a syntax error. The same crack that allows the attack makes the program fail on legitimate data. Code that's vulnerable to injection is also code that doesn't work properly.
- Why it happens: code and data in the same string
The cause fits in one sentence, and it's worth learning by heart:
SQL injection happens because code and data travel mixed together in the same string of text, and the engine has no way of knowing which part you wrote and which part the user wrote.
The server receives a piece of text and parses all of it. To it, OR '1'='1' is SQL just as much as the SELECT in front of it: there's no marker saying "this came from a form". Everything that follows from there —adding conditions, commenting out the rest, chaining statements if the driver allows it— is a consequence of that mixing. And out of it comes the only defence that really works: separate the code from the data, and let the engine put them together knowing which is which.
flowchart LR
A["SQL template"] --> C["⚠️ a single string"] --> D["the parser sees ONE text:<br/>it can't tell code from data"]
B["user input"] --> C
E["✅ SQL with placeholders $1, ?"] --> G["the engine parses and<br/>plans <b>first</b>"] --> H["and only afterwards receives the<br/>values as <b>typed data</b>"]
F["user input"] --> H
- The defence: parameterized queries
A parameterized query (or prepared statement) sends the server two separate things: the text with placeholders, and the values. The server parses and plans before it sees the values; by the time they arrive there's nothing left to parse and a value cannot turn into code, whatever it contains. You don't even need to escape quotes: the value is never inserted into the text. The same search box, written properly, in four environments:
# Python — psycopg 3
cur.execute("SELECT id, name, price FROM products WHERE active AND name ILIKE %s",
('%' + term + '%',)) # the wildcard goes in the VALUE, not in the SQL// Java — JDBC
PreparedStatement ps = conn.prepareStatement(
"SELECT id, name, price FROM products WHERE active AND name ILIKE ?");
ps.setString(1, "%" + term + "%");
ResultSet rs = ps.executeQuery();// PHP — PDO (with prepared-statement emulation TURNED OFF)
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$st = $pdo->prepare("SELECT id, name, price FROM products WHERE active AND name ILIKE :q");
$st->execute([':q' => '%' . $term . '%']);// Node.js — node-postgres
const { rows } = await client.query(
'SELECT id, name, price FROM products WHERE active AND name ILIKE $1',
[`%${term}%`]);With the malicious input ' OR '1'='1, all four versions return 0 rows: they look, quite literally, for products whose name contains the text ' OR '1'='1. That none exists is exactly the right answer. The attack stops being an attack and becomes a search with no results.
And the mechanism also exists in plain SQL, useful for seeing it without a language in the way:
PREPARE search_product (text) AS
SELECT id, name, price FROM products WHERE active AND name ILIKE $1;
EXECUTE search_product('%oil%'); -- 2 rows
EXECUTE search_product('%'' OR ''1''=''1%'); -- 0 rows: it's just text
DEALLOCATE search_product;Two practical points. The % wildcard goes inside the value, never in the SQL text: ILIKE '%' || $1 || '%' works too, but then you should escape any % and _ the user brings, or a search for 100% will turn into a wildcard. And if you're going to filter by a list of values, don't build the list by concatenating: use WHERE id = ANY($1) passing an array, which every modern driver supports.
Dialect note. The placeholder changes with the driver:
$1, $2in native PostgreSQL and node-postgres,%sin psycopg,?in JDBC, PDO and many others,:namefor named parameters in PDO, SQLAlchemy or JDBI. What doesn't change is the guarantee: as long as the value travels through the parameter channel, it can't become code. Careful with PDO: by default it emulates prepared statements by escaping on the client; turn that off withATTR_EMULATE_PREPARES => falseto use real ones.
- What is not a sufficient defence
| False defence | Why it fails |
|---|---|
| Escaping quotes by hand | You have to get the encoding, the wildcards, the comments and every edge case of the dialect right — every time, in every query and forever. One slip is enough. And it protects nothing where there are no quotes: a concatenated numeric parameter (WHERE id = + input) is injectable without using a single one |
Blacklists of words (DROP, UNION, --) |
They reject legitimate searches ("european union", "a drop of water") and don't cover what you didn't imagine. Filtering by what's forbidden always loses to filtering by what's allowed |
| Hiding error messages | It's a good idea, but as an extra layer: it reduces the information that leaks, it doesn't close the hole. The vulnerability is still there, just blind |
| Validating in the browser / "it's an intranet" | Anyone can call the API without going through your page; and most incidents come from inside or from a compromised account |
| An ORM, and nothing else | It protects 95 % of what it does for you, and stops protecting you the moment you use its raw() / text() / createNativeQuery() with concatenation (11-05) |
The list leaves one conclusion: there are no degrees. Either the value travels through the parameter channel, or it isn't protected.
- The special case: dynamic identifiers
There's one case parameterized queries don't solve, and it's the way injection sneaks into otherwise correct applications: a parameter can't be a table name, a column name, or the direction of an ORDER BY.
# ⚠️ VULNERABLE: the sort column comes from the URL (?sort=price&dir=desc)
sql = f"SELECT id, name, price FROM products ORDER BY {sort} {direction}"
# ✅ CORRECT: allowlist. The user's input picks a KEY, it doesn't supply the SQL.
COLUMNS = {"name": "p.name", "price": "p.price", "date": "p.added_date"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
col = COLUMNS.get(sort, "p.id") # safe default if it isn't on the list
dir_ = DIRECTIONS.get(direction, "ASC")
sql = f"SELECT p.id, p.name, p.price FROM products AS p ORDER BY {col} {dir_}"The key idea: the user doesn't supply text, they pick an option from a set you control. What gets concatenated never comes from the input; it comes from your dictionary. And as a side effect, the default value turns a junk parameter into a reasonable ordering instead of an error.
When the dynamic SQL is built inside PostgreSQL —in a PL/pgSQL function (10-04)— the tool is format() with its type markers:
CREATE OR REPLACE FUNCTION fn_count_rows(p_table text) RETURNS bigint AS $$
DECLARE v_total bigint;
BEGIN
-- %I = IDENTIFIER (quoted if needed) · %L = LITERAL · %s = raw text (⚠️)
EXECUTE format('SELECT COUNT(*) FROM %I', p_table) INTO v_total;
RETURN v_total;
END;
$$ LANGUAGE plpgsql;
SELECT fn_count_rows('order_lines');| fn_count_rows |
|---|
| 47 |
%I applies quote_ident: it quotes the identifier and neutralizes anything odd it carries. Never use %s with user input; %s is concatenation under another name. And even with %I, the right thing is to validate first against the list of allowed tables or against information_schema: %I prevents injection, but it doesn't prevent somebody counting the rows of a table that's none of their business.
- Defence in depth
No single layer is enough on its own; the good news is that they add up.
| Layer | What it does | In practice |
|---|---|---|
| Parameterize | Closes the injection | Mandatory, no exceptions |
| Validate the input | Rejects the absurd before touching the database | Types, lengths, ranges, format, allowlists. id is an integer: convert it and fail if it isn't |
| Least privilege | Limits the damage if something gets through | The website doesn't connect as a superuser (section 8) |
| Discreet errors | Doesn't give away the schema | To the user, "the operation could not be completed" + an incident id; the detail goes to the server log |
| Logging and alerting | Lets you detect it | Log repeated syntax errors: they're the signature of somebody probing |
| Review and testing | Finds it before somebody else does | Look for concatenation in code review; static analysis; test with ' and -- in every field |
On error messages: in production, PostgreSQL's full error tells the user the names of the tables, the columns and the constraints. Log the detail on the server, return a generic message and an id that lets support find the trace; in development, the other way round.
- Permissions and roles in PostgreSQL
This is where the promises from 05-04, 09-05, 10-01 and 10-04 get kept. The model is simpler than it looks, with one central idea:
In PostgreSQL there are no "users" and "groups": there are only roles. A role with
LOGINbehaves like a user; a role withoutLOGINthat's granted to other roles behaves like a group. It's the same object.
CREATE ROLE gs_read; -- a group (no LOGIN)
CREATE ROLE ana LOGIN PASSWORD 'a-nice-long-passphrase'; -- a user
GRANT gs_read TO ana; -- ana inherits the group's permissionsPrivileges are granted with GRANT, withdrawn with REVOKE and applied at different levels:
| Level | Usual privileges | Example |
|---|---|---|
| Database | CONNECT, CREATE, TEMPORARY |
GRANT CONNECT ON DATABASE greenstore TO gs_read; |
| Schema | USAGE (being able to see the objects), CREATE |
GRANT USAGE ON SCHEMA public TO gs_read; |
| Table / view | SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER |
GRANT SELECT ON orders TO gs_read; |
| Column | SELECT (col, …), UPDATE (col, …) |
GRANT SELECT (id, name, city, country) ON customers TO gs_read; |
| Sequence / function | USAGE, SELECT, UPDATE / EXECUTE |
Sequences are needed to INSERT into tables with IDENTITY |
Two traps that catch everybody:
USAGEon the schema is essential. Without it,GRANT SELECT ON ordersis useless: the role has permission on a table it can't reach. The error ispermission denied for schema publicand it's baffling because the table'sGRANTis right there.ALTER DEFAULT PRIVILEGES, or new objects inherit nothing. AGRANT SELECT ON ALL TABLES IN SCHEMA publicaffects the tables that exist at that moment. The table you create tomorrow won't be included, and the failure will show up in production after the deploy:
GRANT SELECT ON ALL TABLES IN SCHEMA public TO gs_read; -- today's
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO gs_read; -- and tomorrow'sCareful: ALTER DEFAULT PRIVILEGES applies to objects created by the role that runs the command. If migrations are run by gs_admin, you have to run it as gs_admin (or with FOR ROLE gs_admin).
PUBLIC and the public schema
They're two different things with the same name, and confusing them is the lesson's classic. PUBLIC is a pseudo-role meaning "every role that exists and ever will". public is the default schema where GreenStore's nine tables live.
Historically, PUBLIC had CREATE on the public schema, so any user could create objects there. PostgreSQL 15 fixed that, but in databases created earlier, or migrated, it's worth checking and hardening:
REVOKE CREATE ON SCHEMA public FROM PUBLIC; -- only those who should can create
REVOKE ALL ON DATABASE greenstore FROM PUBLIC; -- including CONNECTAnd remember from module 10 that a view only protects if you revoke the permission on the table: GRANT SELECT ON v_customers_public is worthless while the role keeps SELECT ON customers.
- A role design for GreenStore
Three group roles, three real profiles, and nobody connecting as the owner of the tables:
| Role | For whom | Can | Can't |
|---|---|---|---|
gs_read |
Analysts, dashboards, BI tools | SELECT on the business tables and the views; without customers' email column |
Write anything; see salaries; see email addresses |
gs_app |
The web application | SELECT, INSERT, UPDATE on the operational tables; EXECUTE on sp_confirm_order |
DELETE, DDL, touch employees |
gs_admin |
Migrations and deployments | All the DDL on the schema | It's the owner: never used from the application |
REVOKE ALL ON DATABASE greenstore FROM PUBLIC;
GRANT CONNECT ON DATABASE greenstore TO gs_read, gs_app, gs_admin;
GRANT USAGE ON SCHEMA public TO gs_read, gs_app;
-- gs_read: read only; on customers, only the non-sensitive columns.
-- employees is NOT granted at all: it contains salaries.
GRANT SELECT ON categories, suppliers, products, orders, order_lines, reviews, returns
TO gs_read;
GRANT SELECT (id, name, last_name, city, country, signup_date) ON customers TO gs_read;
-- gs_app: what the website needs, not one privilege more.
-- No DELETE: the site marks orders as cancelled, it doesn't delete (soft delete, 05-04).
GRANT SELECT ON categories, suppliers, products, employees TO gs_app;
GRANT SELECT, INSERT, UPDATE ON customers, orders, order_lines, reviews TO gs_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO gs_app; -- essential for the IDENTITY columns
GRANT EXECUTE ON PROCEDURE sp_confirm_order(int, int, int[], int[]) TO gs_app;
-- The objects gs_admin creates tomorrow will inherit these permissions
ALTER DEFAULT PRIVILEGES FOR ROLE gs_admin IN SCHEMA public GRANT SELECT ON TABLES TO gs_read;
ALTER DEFAULT PRIVILEGES FOR ROLE gs_admin IN SCHEMA public
GRANT SELECT, INSERT, UPDATE ON TABLES TO gs_app;
-- People and services, with LOGIN, inheriting from the group
CREATE ROLE daniel LOGIN PASSWORD '...'; GRANT gs_read TO daniel; -- data analyst
CREATE ROLE web_prod LOGIN PASSWORD '...'; GRANT gs_app TO web_prod;Checking what each role has is as important as granting it:
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privileges
FROM information_schema.role_table_grants
WHERE grantee IN ('gs_read','gs_app') AND table_name = 'orders'
GROUP BY grantee, table_name ORDER BY grantee;| grantee | table_name | privileges |
|---|---|---|
| gs_app | orders | INSERT, SELECT, UPDATE |
| gs_read | orders | SELECT |
In psql, \dp orders gives the same information more compactly. The golden rule: the application never connects as a superuser or as the owner of the tables. If web_prod is the owner, every REVOKE in the world is decorative: the owner can grant everything back to itself.
- Row Level Security
The permissions above reach as far as the table and the column. Row Level Security (RLS) reaches the row: it lets two users run SELECT * FROM orders and get different results. The classic case: each sales rep sees only their own orders.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY pol_orders_salesrep ON orders FOR SELECT TO gs_salesrep
USING (employee_id = current_setting('app.employee_id', true)::int);With SET app.employee_id = '4' at the start of the session, the sales rep Óscar (employee 4) sees 4 orders —numbers 2, 6, 10 and 16— and no others, even if they write SELECT * FROM orders with no WHERE. The ten web orders, with employee_id NULL, aren't visible to anyone under this policy, because NULL = 4 isn't true: if everyone should see them, the condition would be employee_id = ... OR employee_id IS NULL, and that decision has to be made explicitly.
| Filtering in the application | RLS | |
|---|---|---|
| Where the rule lives | In every query of every screen | In the table, once |
If somebody forgets the WHERE |
Data leak | Nothing happens |
Access from psql or a report |
Unprotected | Protected |
| Cost | None | The condition is added to every query: it can rule out good plans |
| Debugging | Straightforward | "Why can't I see my row?" is a hard question |
Three warnings. The table's owner and superusers bypass RLS by default (you need FORCE ROW LEVEL SECURITY). Policies are written per operation —FOR SELECT, FOR INSERT with WITH CHECK, FOR UPDATE, FOR ALL— and if you don't define the INSERT one, nothing can be inserted. And the performance cost is real: the condition goes into every query, so the policy's column has to be indexed. RLS shines in multi-tenant setups (company_id); for three screens and one role, a view is usually enough.
SECURITY DEFINER and the search_path
SECURITY DEFINER and the search_pathThis closes 10-04. By default a function runs with the permissions of whoever calls it (SECURITY INVOKER). With SECURITY DEFINER it runs with those of whoever created it, which lets you give controlled access to data the user can't read: for example, letting an analyst get the average salary without being able to read employees.
CREATE OR REPLACE FUNCTION fn_average_salary() RETURNS numeric
LANGUAGE sql SECURITY DEFINER
SET search_path = public, pg_temp -- ⬅️ ESSENTIAL
AS $$ SELECT ROUND(AVG(salary), 2) FROM employees; $$;
REVOKE EXECUTE ON FUNCTION fn_average_salary() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION fn_average_salary() TO gs_read;
SELECT fn_average_salary();| fn_average_salary |
|---|
| 35037.50 |
The course's canonical average salary, €35,037.50, served without granting access to the table. And now the risk, which is serious: a SECURITY DEFINER function runs with somebody else's privileges, so if the attacker controls which objects it resolves inside it, they execute code with those privileges. The vector is the search_path: if the function says FROM employees unqualified and the caller has put a schema of their own in front containing their own employees table, the function will read the attacker's.
The four rules, and they aren't optional: (1) always set SET search_path = public, pg_temp on every SECURITY DEFINER function (or qualify every object: public.employees); (2) REVOKE EXECUTE ... FROM PUBLIC and grant it only to those who should have it, because by default EXECUTE is granted to PUBLIC; (3) make the function as small as possible and with no dynamic SQL inside —and if there is any, format('%I'/'%L') and an allowlist—; (4) use it only when you have to: SECURITY INVOKER is the default for a good reason.
- Personal data
GreenStore is fictional, but its schema holds exactly the data that in a real system is regulated:
| Column | What it is | Handling |
|---|---|---|
customers.name, last_name |
Identify a person | Restricted access; pseudonymize in test environments |
customers.email |
A direct identifier | Out of gs_read; never in a CSV that circulates |
customers.city, country |
Approximate location | Usually enough for analysis; the detail isn't |
employees.salary |
Sensitive employment data | Only for those who need it; SECURITY DEFINER for aggregates |
orders, order_lines |
The consumption profile of an identified person | Aggregates yes; named detail, restricted |
reviews.comment |
Free text: it can contain anything | Review it before exporting |
The measures you need to know, without getting into legal territory:
- Encryption in transit: TLS mandatory on the connection (
sslmode=requireor better); without it, credentials and data travel in the clear. At rest: disk or volume encryption, and of the backups, which is the one most often forgotten. - Least privilege, which is section 8's point: most analysts don't need to see email addresses. And access logging for sensitive data, not just change logging.
- Pseudonymization for test environments: replace names and emails, shift dates, alter amounts — with the warning that badly done pseudonymization is reversible (a "hash of the email" is reversed by trying email addresses).
- Retention: personal data isn't kept forever. You have to decide how long and then delete or anonymize it (05-04).
⚠️ Before exposing a real system: commission a security review from a professional (code review, configuration and penetration testing) and check the handling of personal data with your organization's data protection officer or legal counsel. This lesson gives you the vocabulary and the minimum practices; it replaces neither an audit nor a compliance analysis.
- Security checklist
Queries. 1. Do all the user's values travel as parameters? Look for concatenation and f-strings with SQL inside. 2. Are there dynamic identifiers (ORDER BY, a table name)? Are they resolved with an allowlist or %I? 3. Are % and _ escaped in LIKE/ILIKE searches?
Permissions. 4. Does the application connect with a role that isn't a superuser or an owner? 5. Does that role have only what it needs, with no gratuitous DELETE or TRUNCATE? 6. Is ALTER DEFAULT PRIVILEGES set up for future tables? 7. Has CREATE on the public schema been revoked from PUBLIC? 8. Do the "security" views have the permission on the base table revoked? 9. Do the SECURITY DEFINER functions set search_path and have REVOKE ... FROM PUBLIC?
Data and operations. 10. Does the connection use TLS, and are the backups encrypted? 11. Do the test environments have fictional or pseudonymized data? 12. Are production error messages generic, with the detail in the log? 13. Are accesses to sensitive data and repeated syntax errors logged and reviewed? 14. Is there a date for the external review before going into production?
Common Mistakes and Tips
- Believing a numeric value isn't injectable.
WHERE id = " + inputis injectable without a single quote. Parameterize always, not just the text fields. - Using an ORM and assuming you're safe. The moment
raw(),text()orcreateNativeQuery()shows up with an f-string, the protection vanishes (11-05). And leaving PDO with prepared-statement emulation on: it escapes on the client instead of using real prepared statements. - Granting
SELECTwithoutUSAGEon the schema.permission denied for schema publicwith the table'sGRANTalready in place: theGRANT USAGE ON SCHEMAis missing. And forgettingALTER DEFAULT PRIVILEGES: everything works until a migration adds a table and the application stops seeing it in production. - Confusing
PUBLIC(the "everyone" pseudo-role) with thepublicschema. AREVOKE ... FROM PUBLICaffects every role, present and future. - Creating a "security" view and leaving the
GRANTon the table. It protects nothing. AndSECURITY DEFINERwithout a fixedsearch_path: that's a textbook privilege escalation. - Tip: search the whole repository for
execute(followed by an f-string or a+. It's the cheapest security review there is and it finds most cases. And test every form field with'and with--: if something breaks on a quote, there's concatenation behind it. - Tip: write your
GRANTs in a migration, not by hand. Permissions are part of the schema and must be reconstructible from the repository (05-06).
Exercises
Exercise 1
This endpoint returns a customer's orders:
def orders_for(customer_id, status, sort):
sql = ("SELECT id, order_date, status FROM orders "
f"WHERE customer_id = {customer_id} AND status = '{status}' ORDER BY {sort}")
return db.execute(sql).fetchall()(1) Point out all three injection paths. (2) Rewrite it correctly. (3) Why isn't the third one fixed the same way as the other two?
Exercise 2
Design the permissions for two new GreenStore profiles: gs_warehouse (Irene, warehouse operator) needs to see paid orders and their lines, and update the order's status to shipped; gs_support (Marc, customer support) needs to see everything about a customer, including their email address, and to create returns. (1) Write the GRANTs. (2) How would you stop gs_warehouse changing the status to cancelled? (3) What is gs_support missing in order to insert into returns?
Exercise 3
A colleague proposes: "Let's give SELECT on everything to the analysts, they're trustworthy, and then they'll stop bothering us asking for permissions". Rebut the proposal with four concrete arguments referring to GreenStore, and propose an alternative that solves their real problem.
Solutions
Solution 1
1. All three: customer_id interpolated without quotes —injectable with 1 OR 1=1, and the one most often overlooked precisely because "it's a number"—; status interpolated inside quotes, injectable by closing them; and sort, which is an identifier and can't be a parameter.
# 2
SORT_OPTIONS = {"date": "order_date DESC", "id": "id", "status": "status, id"}
def orders_for(customer_id, status, sort):
col = SORT_OPTIONS.get(sort, "id") # allowlist
sql = ("SELECT id, order_date, status FROM orders "
f"WHERE customer_id = %s AND status = %s ORDER BY {col}")
return db.execute(sql, (int(customer_id), status)).fetchall()3. Because a parameter is always a value, never structure. ORDER BY $1 would sort by the constant $1, not by the column whose name it holds: the engine has already planned the query by the time it receives the value, and the ordering is part of the plan. That's why identifiers are resolved with an allowlist (or with %I if the SQL is built inside the server), which is a different technique with the same goal: the user's input must never supply SQL text.
Solution 2
-- 1
CREATE ROLE gs_warehouse; CREATE ROLE gs_support;
GRANT CONNECT ON DATABASE greenstore TO gs_warehouse, gs_support;
GRANT USAGE ON SCHEMA public TO gs_warehouse, gs_support;
-- Warehouse: sees what it has to prepare and can only touch the status column
GRANT SELECT ON orders, order_lines, products TO gs_warehouse;
GRANT UPDATE (status) ON orders TO gs_warehouse;
-- Support: the customer's full record and creating returns
GRANT SELECT ON customers, orders, order_lines, products, reviews TO gs_support;
GRANT SELECT, INSERT ON returns TO gs_support;
GRANT USAGE ON SEQUENCE returns_id_seq TO gs_support;2. A GRANT UPDATE (status) lets you change the column, but doesn't control which value you set. That's a business rule and it goes in the database: a BEFORE UPDATE trigger (10-05) that rejects disallowed transitions, or an sp_mark_shipped procedure granted EXECUTE while the direct UPDATE is withdrawn — the second option is cleaner, because it exposes the operation and not the column. 3. It's missing the permission on the sequence —returns_id_seq— without which an INSERT that lets the id be generated fails with permission denied for sequence. It's the most common oversight when granting INSERT, and that's why section 8 has a GRANT USAGE ON ALL SEQUENCES.
Solution 3
The four arguments. (a) customers.email and employees.salary are neither trusted nor distrusted: they're personal data whose access must be limited to those who need it for their job, and a sales analyst doesn't. (b) Trust doesn't protect against accidents: a CSV export, a lost laptop or a shared dashboard turn a legitimate SELECT into a leak. (c) Trust doesn't protect against a compromised account: if Daniel's credentials are stolen, the attacker inherits exactly what Daniel had. (d) Without ALTER DEFAULT PRIVILEGES and with "access to everything", every new table —including a future payroll— is exposed by default, which is precisely the opposite of what should happen.
The alternative, which also solves their real problem (no longer being bothered for permissions): give them gs_read on the analysis views, not on the tables. A layer of v_* views excluding the sensitive columns, with ALTER DEFAULT PRIVILEGES so new views are granted automatically, and a SECURITY DEFINER for the few aggregates that need restricted data —like fn_average_salary. The analyst gains autonomy and the exposure is far smaller.
Conclusion
This lesson closes the least visible half of the course and the one that costs most when it's missing:
- SQL injection happens because code and data travel in the same string. With the input
' OR '1'='1, a concatenated search box goes from returning 2 products to returning all 19 active ones; with a--in there, all 20, including the discontinued one. And the same flaw makesO'Connorbreak the query. The defence is one thing and it's total: parameterized queries. The server parses and plans before it sees the values, so a value can't turn into code. The same in psycopg, JDBC, PDO and node-postgres, and in plain SQL withPREPARE/EXECUTE. - These are not defences: escaping by hand, blacklists, hiding errors, validating in the browser or "using an ORM". And identifiers can't be parameterized: they go through an allowlist, or through
format('%I')/quote_identinside the server. Never%s. - Permissions: in PostgreSQL everything is a role;
GRANT/REVOKEon database, schema, table, column, sequence and function;USAGEon the schema is essential andALTER DEFAULT PRIVILEGESis what makes future objects inherit.PUBLIC(every role) isn't thepublicschema, and a view only protects if the permission on the table is revoked. - GreenStore's design:
gs_readfor analysts withoutemailoremployees,gs_appwithoutDELETEor DDL,gs_adminonly for migrations. And the golden rule: the application never connects as a superuser or as the owner. - RLS filters by row —sales rep 4 sees their 4 orders and no others— at the price of a condition on every query and harder debugging;
SECURITY DEFINERlends privileges and demandsSET search_pathandREVOKE EXECUTE FROM PUBLIC. And personal data: identify which columns qualify, TLS in transit, encryption at rest and of the backups, pseudonymization in test environments, retention — with a review by a security professional and by your legal officer before exposing anything real.
With this the system is protected and maintainable. Now to get value out of it. In the next lesson, SQL for data analysis, you'll see the analyst's craft: the flow that runs from the business question to the well-defined metric —and why half of analysis errors are definitional and not about SQL: does "sales" include shipping? what about the cancelled order?—; GreenStore's fundamental metrics computed one by one; time analysis with running totals and moving averages; segmentation and the Pareto of customers and products; cohorts with their honest warning about sample size; presentation with CASE and with crosstab, closing 06-05's promise; and where SQL fits against Python and BI tools.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
