diff --git a/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.mdx b/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.mdx
new file mode 100644
index 0000000..84707a4
--- /dev/null
+++ b/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.mdx
@@ -0,0 +1,149 @@
+Postgres has no separate notion of "users" and "groups" — there is one thing, a **role**, and access control is built entirely out of granting privileges to roles. This lesson covers the two layers: table- and schema-level privileges with `GRANT`/`REVOKE`, and row-level security, where a policy decides which *rows* each role may see.
+
+The seed is a shared document store: a `documents` table where each row has an `owner` (a role name). Two people, alice and bob, keep their notes in the same table.
+
+
+SELECT * FROM documents ORDER BY id;
+
+
+## Roles are users and groups at once
+
+A role is just a named grantee. Give it `LOGIN` and it can connect — that's what we informally call a "user." Leave `LOGIN` off and it's effectively a "group": nobody logs in as it, but you can grant privileges to it and then grant *membership* in it to other roles. Same object, different use.
+
+Creating and wiring up roles needs the `CREATEROLE` attribute (or superuser), which your sandbox role doesn't have — so these are read-only snippets, not runnable blocks:
+
+```sql
+-- A login role (a "user") and a group role (no LOGIN)
+CREATE ROLE alice LOGIN PASSWORD 'secret';
+CREATE ROLE analysts;
+
+-- Membership: alice now inherits everything granted to analysts
+GRANT analysts TO alice;
+```
+
+Grant a privilege to `analysts` and every member gets it — that's how you manage access by team instead of per person. One special role, `PUBLIC`, means *every role, present and future*. Granting to `PUBLIC` is convenient and dangerous: it's the usual reason a table is readable by accounts you forgot existed.
+
+## Object privileges: GRANT and REVOKE
+
+Privileges on a table are handed out with `GRANT` and taken back with `REVOKE`. You can do this on tables **you own** without any special attribute, so these run. Grant `PUBLIC` read and write, then immediately think better of the write:
+
+
+GRANT SELECT, INSERT ON documents TO PUBLIC;
+
+
+
+REVOKE INSERT ON documents FROM PUBLIC;
+
+
+The lesson here is **least privilege**: grant the narrowest set that gets the job done, and prefer granting to a group role over `PUBLIC`. Every extra privilege is a path an attacker or a buggy app can take.
+
+Table grants aren't the whole story. To touch anything in a schema, a role also needs privileges on the *schema*:
+
+```sql
+GRANT USAGE ON SCHEMA app TO analysts; -- may reference objects inside
+GRANT CREATE ON SCHEMA app TO deployer; -- may create new objects inside
+```
+
+`USAGE` is the "you may look inside this schema" key; `CREATE` lets a role add tables of its own. A role with `SELECT` on a table but no `USAGE` on its schema still can't read it.
+
+New tables don't inherit anyone's grants — a freshly created table is private to its owner. If you want a group to see everything created *from now on*, set a default with `ALTER DEFAULT PRIVILEGES`:
+
+
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO PUBLIC;
+
+
+That only affects tables created *after* the statement, and only ones created by the role that ran it — it's a rule for the future, not a retroactive grant.
+
+## Row-level security: privileges on rows
+
+`GRANT` decides who may touch a table. **Row-level security (RLS)** decides *which rows* they see once they're in. You enable it on the table, then attach one or more policies:
+
+
+ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
+
+
+The moment RLS is on, the default is *deny*: with no policy, non-owners see zero rows. A policy pokes holes in that wall. There's a catch, though — the **table owner bypasses RLS entirely** by default. Since you own `documents`, you'd still see every row, which makes the feature impossible to feel. `FORCE ROW LEVEL SECURITY` makes the owner obey policies too:
+
+
+ALTER TABLE documents FORCE ROW LEVEL SECURITY;
+
+
+## USING vs WITH CHECK
+
+A policy has two halves. `USING` is the **visibility** filter — it's applied to existing rows for `SELECT`/`UPDATE`/`DELETE`, and any row failing it simply isn't there. `WITH CHECK` is the **write** filter — it's applied to the new row an `INSERT` or `UPDATE` produces, and a failing row is rejected with an error. Roughly: `USING` controls what you can read, `WITH CHECK` controls what you can write.
+
+In production the policy keys off `current_user`, so each connected role sees its own rows:
+
+```sql
+CREATE POLICY owner_can_see ON documents
+ USING (owner = current_user)
+ WITH CHECK (owner = current_user);
+```
+
+Your sandbox has just one role, so `current_user` never changes — you couldn't watch it filter. Instead we'll key the policy off a session variable *you* can set, standing in for "who am I right now":
+
+
+CREATE POLICY owner_can_see ON documents
+ USING (owner = current_setting('app.owner', true))
+ WITH CHECK (owner = current_setting('app.owner', true));
+
+
+Now become alice and look — the wall lets exactly her rows through:
+
+
+SET app.owner = 'alice';
+SELECT id, owner, title FROM documents ORDER BY id;
+
+
+Three rows, all alice's. Switch to bob and the same query returns a different world:
+
+
+SET app.owner = 'bob';
+SELECT id, owner, title FROM documents ORDER BY id;
+
+
+That's `USING` at work — one table, one query, per-role results. Now `WITH CHECK`: still acting as bob, try to plant a row owned by alice. This one is *supposed to fail* — the write filter rejects it because the new row's owner isn't bob:
+
+
+INSERT INTO documents (owner, title) VALUES ('alice', 'sneaky');
+
+
+Postgres refuses: *new row violates row-level security policy*. A row bob may not read is also a row bob may not create. Insert one he's allowed to, and it goes through:
+
+
+INSERT INTO documents (owner, title) VALUES ('bob', 'Retro notes');
+
+
+## Your turn
+
+You've already done the exercise above — but make sure the final state is in place, because that's what we'll verify. `documents` should have row-level security **enabled** and carry an owner-scoped policy. If you ran the blocks in order, both are done; if not, run these:
+
+
+ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
+
+
+
+CREATE POLICY owner_can_see ON documents
+ USING (owner = current_setting('app.owner', true))
+ WITH CHECK (owner = current_setting('app.owner', true));
+
+
+Confirm the flag is set — this is the check:
+
+
+SELECT relrowsecurity FROM pg_class WHERE relname = 'documents';
+
+
+
+Enable row-level security on `documents` and add an owner-based policy. We'll confirm `relrowsecurity` is `true`.
+
+
+## What you learned
+
+- A **role** is Postgres's single concept for both users (with `LOGIN`) and groups (membership granted with `GRANT role TO role`); `PUBLIC` is every role at once, so grant to it sparingly.
+- **Object privileges** are handed out with `GRANT` and pulled back with `REVOKE`; reaching into a schema also needs `USAGE` (look inside) or `CREATE` (add objects). Follow least privilege and lean on group roles.
+- `ALTER DEFAULT PRIVILEGES` sets grants for tables created *later* — it's a rule for the future, not retroactive.
+- **Row-level security** filters rows: `ENABLE ROW LEVEL SECURITY` flips to deny-by-default, then a `CREATE POLICY` grants access. `USING` controls visibility (reads), `WITH CHECK` controls writes.
+- The table **owner bypasses RLS** unless you add `FORCE ROW LEVEL SECURITY`.
+
+Up next: vacuum and bloat — keeping MVCC tidy.
diff --git a/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.yaml b/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.yaml
new file mode 100644
index 0000000..931f584
--- /dev/null
+++ b/lessons/10-expert-and-operations/01-roles-and-privileges/lesson.yaml
@@ -0,0 +1,20 @@
+title: Roles and privileges
+summary: "Postgres has one concept for users and groups — roles — and two ways to control access: GRANT/REVOKE on objects and row-level security policies."
+estimatedMinutes: 15
+tags:
+ - roles
+ - privileges
+ - grant
+ - row-level-security
+ - rls
+authors:
+ - exekias
+seed: seed.sql
+checks:
+ - id: rls-enabled
+ type: query-returns
+ description: Enable row-level security on documents and add an owner-based policy.
+ sql: SELECT relrowsecurity FROM pg_class WHERE relname = 'documents'
+ expect:
+ rowCount: 1
+ rows: [[true]]
diff --git a/lessons/10-expert-and-operations/01-roles-and-privileges/seed.sql b/lessons/10-expert-and-operations/01-roles-and-privileges/seed.sql
new file mode 100644
index 0000000..b2b0c6a
--- /dev/null
+++ b/lessons/10-expert-and-operations/01-roles-and-privileges/seed.sql
@@ -0,0 +1,17 @@
+-- Seed for "01-roles-and-privileges": a tiny shared document store. documents
+-- holds a handful of rows owned by two people (owner is a plain role name), so
+-- a row-level security policy can later hand each owner only their own slice of
+-- the same table.
+
+CREATE TABLE documents (
+ id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ owner text NOT NULL,
+ title text NOT NULL
+);
+
+INSERT INTO documents (owner, title) VALUES
+ ('alice', 'Q3 roadmap'),
+ ('alice', 'Hiring plan'),
+ ('alice', '1:1 notes'),
+ ('bob', 'Migration runbook'),
+ ('bob', 'On-call schedule');
diff --git a/lessons/10-expert-and-operations/module.yaml b/lessons/10-expert-and-operations/module.yaml
new file mode 100644
index 0000000..c902b7c
--- /dev/null
+++ b/lessons/10-expert-and-operations/module.yaml
@@ -0,0 +1,3 @@
+title: Expert and operations
+difficulty: advanced
+summary: Operate Postgres with confidence — roles and row-level security, vacuum and bloat, extensions, and troubleshooting.