Skip to content

Commit 575570c

Browse files
ibrarahmadmason-sharp
authored andcommitted
Stop recording pg_shdepend rows lolor cannot describe.
Creating a large object recorded a shared dependency whose classId was the OID of lolor.pg_largeobject, an ordinary table rather than a catalog the dependency machinery can describe. DROP ROLE on any role that had created one failed with "unrecognized object class", and the rows were never removed because inv_drop() deleted with PERFORM_DELETION_SKIP_ORIGINAL. pg_shdepend is shared across the cluster, so the stored classId was a per-database relation OID meaningless in any other database. Objects in lolor storage are rows in ordinary tables and cannot take part in pg_shdepend at all, so drop the recording and the matching deletion, and remove the rows earlier versions left behind. Ownership is consequently untracked, which is inherent to storing large objects outside the catalogs; add lolor.check_orphans() to report objects whose owner is gone.
1 parent 1952866 commit 575570c

6 files changed

Lines changed: 492 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,6 @@ database user. Upgrading to 1.3.0 revokes the privilege; see the release notes.
124124

125125
- Native large object functionality cannot be used while you are using the lolor extension.
126126
- lolor does not support the following statements: `ALTER LARGE OBJECT`, `GRANT ON LARGE OBJECT`, `COMMENT ON LARGE OBJECT`, and `REVOKE ON LARGE OBJECT`.
127+
- Objects in lolor storage are rows in ordinary tables and so cannot participate in `pg_shdepend`. `DROP ROLE`, `DROP OWNED BY` and `REASSIGN OWNED BY` do not see them: a role that owns them or appears in their ACL can be dropped without a warning, and `REASSIGN OWNED` / `DROP OWNED` leave them untouched. Before dropping a role, run `REASSIGN OWNED BY` or `DROP OWNED BY` in each database that has lolor, then `DROP ROLE`. Afterwards run `lolor.check_orphans()` in each such database and repair anything it reports with `lolor.fix_orphans(new_owner)`.
128+
- Role OIDs come from a cluster-wide counter that wraps around, so a new role can receive a dropped role's OID and silently become the owner or grantee of that role's orphaned objects. This cannot be detected after the fact, which is another reason to run `lolor.check_orphans()` promptly after dropping roles.
127129
- Large object migration is node-local. Native large objects live in `pg_catalog.pg_largeobject`, which is never replicated, so each node holds an independent set and `migrate_from_native()` migrates only the local node's objects; with spock installed, the migration DML runs in repair mode and is not replicated. Run the migration on every node that holds native large objects — for example with `spock.replicate_ddl('SELECT lolor.migrate_from_native()')`, which queues the command so that each node executes it locally. Migrated objects keep their original native OIDs, which are not node-encoded: if different nodes hold different objects under the same OID, the nodes' lolor contents will diverge and later replicated changes to those objects can conflict. Newly created large objects are collision-free, since new OIDs are node-encoded via `lolor.node` and checked against existing rows.

‎docs/lolor_release_notes.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
* **Security fix: `lo_import()` and `lo_export()` were executable by any database user.** These read and write files on the server as the account PostgreSQL runs under, so core revokes `EXECUTE` on them from `PUBLIC`. lolor replaces them by renaming the originals to `*_orig`; an ACL belongs to a function rather than a name, so the restriction stayed on the parked original while each replacement got the default `EXECUTE TO PUBLIC`. Any user could read an arbitrary server file with `lo_import()` or overwrite one with `lo_export()`. The replacements are now locked down at install time, and upgrading revokes the privilege on existing installations in either state. Versions 1.0 through 1.2.2 are affected.
1212
* The extension is no longer marked `trusted`. Installing lolor renames functions in `pg_catalog` for the whole database, which a non-superuser should not be able to do.
1313
* Fixed the `lolor.node` upper bound. The GUC accepted 0..16 while a generated OID reserves four bits for the node id, so node 16 did not fit: the node field of every OID it generated read back as 0. The bound is now derived from the encoding (`LOLOR_MAX_NODE_ID`), giving 0..15. **If you have `lolor.node = 16` configured**, the server still starts but logs `16 is outside the valid range for parameter "lolor.node" (0 .. 15)` and falls back to 0, which is the node id it was effectively using already. Set it to a value in 0..15, and check for OID collisions against whichever node is genuinely 0.
14+
* **Fixed `DROP ROLE` failing with "unrecognized object class".** Creating a large object recorded a `pg_shdepend` row whose `classId` was the OID of `lolor.pg_largeobject`, an ordinary table rather than a catalog. Any role that had created a large object became undroppable, and the rows were never cleaned up because `inv_drop()` deleted with `PERFORM_DELETION_SKIP_ORIGINAL`. lolor no longer records these rows, and the upgrade removes the ones already present.
15+
* The cleanup is per database, so `ALTER EXTENSION lolor UPDATE` must be run in every database that has lolor. Until it is, `DROP ROLE` anywhere in the cluster keeps reporting "owner of objects in database X".
16+
* A database that ran `DROP EXTENSION lolor` on an earlier version still has the rows, now pointing at a table that no longer exists, and no upgrade script will run there. Clean it by hand as superuser in that database: find the stale class OIDs with `SELECT DISTINCT classid FROM pg_shdepend WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) AND classid NOT IN (SELECT oid FROM pg_class)`, then `DELETE FROM pg_shdepend WHERE dbid = <that dbid> AND classid IN (<those OIDs>)`.
17+
* New helpers `lolor.check_orphans()` and `lolor.fix_orphans(new_owner)` for objects whose owner, grantee or grantor has been dropped. Objects in lolor storage cannot participate in `pg_shdepend`, so `DROP ROLE` does not notice them; `check_orphans()` lists them and which reference is dangling, and `fix_orphans()` reassigns dead owners, replacing them in the ACL as `REASSIGN OWNED` would so that grants they made survive, and drops entries that still name a missing role.
1418
* Expanded test coverage: TAP tests for dump/restore, streaming and logical replication, and standby promotion; regression tests for `lo_lseek`, `lo_tell`, and `lo_truncate`.
1519
* Security hardening: addressed Codacy/Flawfinder warnings.
1620

‎expected/lolor.out‎

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,247 @@ ERROR: 16 is outside the valid range for parameter "lolor.node" (0 .. 15)
730730
SET lolor.node = 15;
731731
SET lolor.node = 1;
732732
--
733+
-- Permission enforcement.
734+
--
735+
-- Objects in lolor storage have no catalog entry, so lolor cannot use the
736+
-- syscache-backed owner and ACL checks and reimplements them against its own
737+
-- tables. Exercise that path rather than assuming it matches core.
738+
--
739+
CREATE EXTENSION lolor;
740+
CREATE ROLE lolor_alice;
741+
CREATE ROLE lolor_bob;
742+
-- Large object error messages quote the OID, which is generated and so
743+
-- differs between runs. Report the message with digits masked instead.
744+
CREATE FUNCTION lolor_expect_error(cmd text) RETURNS text AS $$
745+
BEGIN
746+
EXECUTE cmd;
747+
RETURN 'unexpectedly succeeded';
748+
EXCEPTION WHEN OTHERS THEN
749+
RETURN regexp_replace(SQLERRM, '[0-9]+', 'NNN', 'g');
750+
END
751+
$$ LANGUAGE plpgsql;
752+
SET ROLE lolor_alice;
753+
SELECT lo_from_bytea(0, 'alice private data') AS alice_oid \gset
754+
SELECT convert_from(lo_get(:alice_oid), 'UTF8') AS owner_can_read;
755+
owner_can_read
756+
--------------------
757+
alice private data
758+
(1 row)
759+
760+
RESET ROLE;
761+
-- A different role gets nothing without a grant.
762+
SET ROLE lolor_bob;
763+
SELECT lolor_expect_error(format('SELECT lo_get(%s)', :alice_oid)) AS read_denied;
764+
read_denied
765+
----------------------------------------
766+
permission denied for large object NNN
767+
(1 row)
768+
769+
SELECT lolor_expect_error(format('SELECT lo_open(%s, 262144)', :alice_oid)) AS open_denied;
770+
open_denied
771+
----------------------------------------
772+
permission denied for large object NNN
773+
(1 row)
774+
775+
SELECT lolor_expect_error(format('SELECT lo_put(%s, 0, ''x'')', :alice_oid)) AS write_denied;
776+
write_denied
777+
----------------------------------------
778+
permission denied for large object NNN
779+
(1 row)
780+
781+
SELECT lolor_expect_error(format('SELECT lo_unlink(%s)', :alice_oid)) AS unlink_denied;
782+
unlink_denied
783+
-----------------------------------
784+
must be owner of large object NNN
785+
(1 row)
786+
787+
RESET ROLE;
788+
-- The superuser bypasses the check, as in core.
789+
SELECT convert_from(lo_get(:alice_oid), 'UTF8') AS superuser_can_read;
790+
superuser_can_read
791+
--------------------
792+
alice private data
793+
(1 row)
794+
795+
-- GRANT/ALTER on a large object act on pg_largeobject_metadata, where an
796+
-- object in lolor storage has no row. This limitation is documented; assert
797+
-- it so that a change in behaviour is noticed.
798+
SELECT lolor_expect_error(
799+
format('GRANT SELECT ON LARGE OBJECT %s TO lolor_bob', :alice_oid)) AS grant_unsupported;
800+
grant_unsupported
801+
---------------------------------
802+
large object NNN does not exist
803+
(1 row)
804+
805+
SELECT lolor_expect_error(
806+
format('ALTER LARGE OBJECT %s OWNER TO lolor_bob', :alice_oid)) AS alter_unsupported;
807+
alter_unsupported
808+
---------------------------------
809+
large object NNN does not exist
810+
(1 row)
811+
812+
SELECT lo_unlink(:alice_oid);
813+
lo_unlink
814+
-----------
815+
1
816+
(1 row)
817+
818+
--
819+
-- Objects in lolor storage are rows in ordinary tables and cannot participate
820+
-- in pg_shdepend, so DROP ROLE does not notice that a role still owns one.
821+
-- lolor.check_orphans() exists to make the consequence findable.
822+
--
823+
SET ROLE lolor_alice;
824+
SELECT lo_from_bytea(0, 'owned by a role about to vanish') AS orphan_oid \gset
825+
RESET ROLE;
826+
SELECT count(*) AS orphans_before FROM lolor.check_orphans();
827+
orphans_before
828+
----------------
829+
0
830+
(1 row)
831+
832+
DROP ROLE lolor_alice;
833+
SELECT count(*) AS orphans_after FROM lolor.check_orphans();
834+
orphans_after
835+
---------------
836+
1
837+
(1 row)
838+
839+
SELECT lo_unlink(:orphan_oid);
840+
lo_unlink
841+
-----------
842+
1
843+
(1 row)
844+
845+
SELECT count(*) AS orphans_cleared FROM lolor.check_orphans();
846+
orphans_cleared
847+
-----------------
848+
0
849+
(1 row)
850+
851+
--
852+
-- fix_orphans() must behave like REASSIGN OWNED: grants the dead owner made
853+
-- survive with the new owner as grantor, an entry the new owner already held
854+
-- merges into its owner entry, and only entries that still name a missing
855+
-- role are dropped. ACLs can only be set in native storage, so build them
856+
-- there and migrate in.
857+
--
858+
CREATE ROLE lolor_dead_owner;
859+
CREATE ROLE lolor_carol;
860+
CREATE ROLE lolor_dave;
861+
SELECT lolor.disable();
862+
disable
863+
---------
864+
t
865+
(1 row)
866+
867+
SET ROLE lolor_dead_owner;
868+
SELECT lo_from_bytea(0, 'granted to bob') AS granted_oid \gset
869+
GRANT SELECT ON LARGE OBJECT :granted_oid TO lolor_bob;
870+
SELECT lo_from_bytea(0, 'grant chain') AS chain_oid \gset
871+
GRANT SELECT ON LARGE OBJECT :chain_oid TO lolor_dave WITH GRANT OPTION;
872+
SELECT lo_from_bytea(0, 'new owner already a grantee') AS merge_oid \gset
873+
GRANT SELECT ON LARGE OBJECT :merge_oid TO lolor_carol;
874+
RESET ROLE;
875+
SET ROLE lolor_dave;
876+
GRANT SELECT ON LARGE OBJECT :chain_oid TO lolor_bob;
877+
RESET ROLE;
878+
SELECT lolor.enable();
879+
enable
880+
--------
881+
t
882+
(1 row)
883+
884+
SELECT lolor.migrate_from_native();
885+
NOTICE: migrated 12 large object(s) (12 data page(s)) from native to lolor storage
886+
migrate_from_native
887+
---------------------
888+
12
889+
(1 row)
890+
891+
DROP ROLE lolor_dead_owner;
892+
SELECT role_kind, count(*) FROM lolor.check_orphans() GROUP BY 1 ORDER BY 1;
893+
role_kind | count
894+
-----------+-------
895+
grantee | 3
896+
grantor | 3
897+
owner | 3
898+
(3 rows)
899+
900+
SELECT lolor.fix_orphans('lolor_carol') AS objects_repaired;
901+
objects_repaired
902+
------------------
903+
3
904+
(1 row)
905+
906+
SELECT count(*) AS orphans_left FROM lolor.check_orphans();
907+
orphans_left
908+
--------------
909+
0
910+
(1 row)
911+
912+
-- Role OIDs vary per run, so compare the rebuilt ACLs by name
913+
SELECT CASE m.oid WHEN :granted_oid THEN 'granted' WHEN :chain_oid THEN 'chain' ELSE 'merge' END AS obj,
914+
pg_get_userbyid(m.lomowner) AS owner,
915+
a.grantee::regrole::text AS grantee, a.grantor::regrole::text AS grantor, a.privilege_type
916+
FROM lolor.pg_largeobject_metadata m, aclexplode(m.lomacl) a
917+
WHERE m.oid IN (:granted_oid, :chain_oid, :merge_oid)
918+
ORDER BY 1, 3, 4, 5;
919+
obj | owner | grantee | grantor | privilege_type
920+
---------+-------------+-------------+-------------+----------------
921+
chain | lolor_carol | lolor_bob | lolor_dave | SELECT
922+
chain | lolor_carol | lolor_carol | lolor_carol | SELECT
923+
chain | lolor_carol | lolor_carol | lolor_carol | UPDATE
924+
chain | lolor_carol | lolor_dave | lolor_carol | SELECT
925+
granted | lolor_carol | lolor_bob | lolor_carol | SELECT
926+
granted | lolor_carol | lolor_carol | lolor_carol | SELECT
927+
granted | lolor_carol | lolor_carol | lolor_carol | UPDATE
928+
merge | lolor_carol | lolor_carol | lolor_carol | SELECT
929+
merge | lolor_carol | lolor_carol | lolor_carol | UPDATE
930+
(9 rows)
931+
932+
-- The grantee kept access and the new owner has it
933+
SET ROLE lolor_bob;
934+
SELECT convert_from(lo_get(:granted_oid), 'UTF8') AS bob_still_reads;
935+
bob_still_reads
936+
-----------------
937+
granted to bob
938+
(1 row)
939+
940+
RESET ROLE;
941+
SET ROLE lolor_carol;
942+
SELECT convert_from(lo_get(:chain_oid), 'UTF8') AS new_owner_reads;
943+
new_owner_reads
944+
-----------------
945+
grant chain
946+
(1 row)
947+
948+
RESET ROLE;
949+
SELECT lo_unlink(:granted_oid);
950+
lo_unlink
951+
-----------
952+
1
953+
(1 row)
954+
955+
SELECT lo_unlink(:chain_oid);
956+
lo_unlink
957+
-----------
958+
1
959+
(1 row)
960+
961+
SELECT lo_unlink(:merge_oid);
962+
lo_unlink
963+
-----------
964+
1
965+
(1 row)
966+
967+
DROP ROLE lolor_carol;
968+
DROP ROLE lolor_dave;
969+
DROP ROLE lolor_bob;
970+
DROP FUNCTION lolor_expect_error(text);
971+
DROP EXTENSION lolor;
972+
NOTICE: migrated 9 large object(s) from lolor to native storage
973+
--
733974
-- 64-bit interface and page-boundary I/O. lo_put(), lo_tell64() and
734975
-- lo_truncate64() had no coverage.
735976
--
@@ -1050,6 +1291,8 @@ SELECT lo_unlink(:multi_oid);
10501291
--
10511292
SELECT lo_get(0);
10521293
ERROR: large object 0 does not exist
1294+
SELECT lo_unlink(0);
1295+
ERROR: large object 0 does not exist
10531296
BEGIN;
10541297
SELECT lo_open(0, 262144);
10551298
ERROR: large object 0 does not exist

‎lolor--1.2.2--1.3.0.sql‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,114 @@ BEGIN
4747
END;
4848
$$;
4949

50+
/*
51+
* Large objects in lolor storage that refer to a role which no longer exists,
52+
* as owner, grantee or grantor.
53+
*
54+
* Objects in lolor storage are rows in ordinary tables, so they cannot
55+
* participate in pg_shdepend: DROP ROLE will not notice them the way it
56+
* notices native large objects. That is inherent to storing them outside the
57+
* catalogs; this function makes the consequence findable, and fix_orphans()
58+
* repairs it. Joins pg_roles rather than pg_authid so that a grantee of
59+
* EXECUTE can actually run it.
60+
*/
61+
CREATE FUNCTION lolor.check_orphans()
62+
RETURNS TABLE (loid oid, role_oid oid, role_kind text) AS $$
63+
SELECT m.oid, m.lomowner, 'owner'
64+
FROM lolor.pg_largeobject_metadata m
65+
WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = m.lomowner)
66+
UNION
67+
SELECT m.oid, a.grantee, 'grantee'
68+
FROM lolor.pg_largeobject_metadata m, pg_catalog.aclexplode(m.lomacl) a
69+
WHERE a.grantee <> 0
70+
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = a.grantee)
71+
UNION
72+
SELECT m.oid, a.grantor, 'grantor'
73+
FROM lolor.pg_largeobject_metadata m, pg_catalog.aclexplode(m.lomacl) a
74+
WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = a.grantor)
75+
ORDER BY 1, 3, 2
76+
$$ LANGUAGE sql STABLE;
77+
78+
REVOKE ALL ON FUNCTION lolor.check_orphans() FROM PUBLIC;
79+
80+
/*
81+
* Repair what check_orphans() reports. Objects whose owner is gone go to
82+
* new_owner, and the ACL is rebuilt the way core's aclnewowner() does it: the
83+
* old owner is replaced by new_owner wherever it appears as grantee or
84+
* grantor, entries that then coincide are merged, and only entries that still
85+
* name a missing role are dropped. Grants the old owner made therefore
86+
* survive, as they do under REASSIGN OWNED. Returns the number of objects
87+
* changed.
88+
*
89+
* One UPDATE is essential: the ACL rewrite has to see the old owner, and a
90+
* separate UPDATE of lomowner would already have lost it.
91+
*/
92+
CREATE FUNCTION lolor.fix_orphans(new_owner regrole)
93+
RETURNS bigint AS $$
94+
DECLARE
95+
fixed bigint;
96+
BEGIN
97+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = current_user AND rolsuper) THEN
98+
RAISE EXCEPTION 'must be superuser to repair orphaned large objects';
99+
END IF;
100+
101+
WITH o AS (
102+
SELECT m.oid, m.lomowner AS old_owner, m.lomacl,
103+
NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = m.lomowner) AS owner_dead
104+
FROM lolor.pg_largeobject_metadata m)
105+
UPDATE lolor.pg_largeobject_metadata m
106+
SET lomowner = CASE WHEN o.owner_dead THEN new_owner::oid ELSE m.lomowner END,
107+
-- aclexplode() yields one row per privilege. Substitute the owner,
108+
-- regroup so coinciding entries merge, then rebuild with
109+
-- makeaclitem(). An empty result is NULL, meaning default
110+
-- privileges.
111+
lomacl = (
112+
SELECT array_agg(pg_catalog.makeaclitem(s.grantee, s.grantor, s.privs, s.is_grantable)
113+
ORDER BY s.grantee, s.grantor, s.is_grantable)
114+
FROM (SELECT CASE WHEN o.owner_dead AND a.grantee = o.old_owner
115+
THEN new_owner::oid ELSE a.grantee END AS grantee,
116+
CASE WHEN o.owner_dead AND a.grantor = o.old_owner
117+
THEN new_owner::oid ELSE a.grantor END AS grantor,
118+
a.is_grantable,
119+
string_agg(DISTINCT a.privilege_type, ',') AS privs
120+
FROM pg_catalog.aclexplode(o.lomacl) a
121+
GROUP BY 1, 2, 3) s
122+
WHERE (s.grantee = 0
123+
OR EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = s.grantee))
124+
AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = s.grantor))
125+
FROM o
126+
WHERE m.oid = o.oid
127+
AND (o.owner_dead
128+
OR EXISTS (SELECT 1 FROM pg_catalog.aclexplode(o.lomacl) a
129+
WHERE (a.grantee <> 0
130+
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = a.grantee))
131+
OR NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles r WHERE r.oid = a.grantor)));
132+
GET DIAGNOSTICS fixed = ROW_COUNT;
133+
134+
RETURN fixed;
135+
END;
136+
$$ LANGUAGE plpgsql VOLATILE;
137+
138+
REVOKE ALL ON FUNCTION lolor.fix_orphans(regrole) FROM PUBLIC;
139+
140+
/*
141+
* Remove the bogus pg_shdepend rows left by earlier versions.
142+
*
143+
* Through 1.2.2, creating a large object recorded a pg_shdepend row whose
144+
* classId was the OID of lolor.pg_largeobject -- an ordinary table, not a
145+
* catalog the dependency machinery can describe. DROP ROLE on any role that
146+
* had created one failed with "unrecognized object class", and the rows were
147+
* never removed.
148+
*
149+
* pg_shdepend is shared across the cluster, so restrict the delete to this
150+
* database: the same classId in another database is an unrelated relation.
151+
*/
152+
DELETE FROM pg_catalog.pg_shdepend
153+
WHERE dbid = (SELECT oid FROM pg_catalog.pg_database
154+
WHERE datname = current_database())
155+
AND classid IN ('lolor.pg_largeobject'::regclass,
156+
'lolor.pg_largeobject_metadata'::regclass);
157+
50158
/*
51159
* lolor.migrate_from_native()
52160
*

0 commit comments

Comments
 (0)