Repository navigation
[security] Close the SECURITY DEFINER PUBLIC-grant class (closes #25) - #41
Merged
Merged
Conversation
Postgres grants EXECUTE on every new function to PUBLIC by default, so the
`GRANT ... TO service_role` sitting next to a SECURITY DEFINER function
excluded nobody. Verified on a from-scratch stack with only the anon key --
the key that ships in the web bundle:
* due_coach_nudges(int) returned another member's user_id, display_name,
coach name and discipline. It has no auth.uid() filter at all; it was
written as a service_role dispatcher query. This one discloses that a
named person is being coached, and on what.
* consume_free_message() incremented messages_used three times over three
unauthenticated calls, exhausting a member's free tier.
* has_coach_access() answered for an arbitrary member id -- and the id it
needs leaks from due_coach_nudges.
* check_subscription_access / get_trial_days_remaining / can_publish_coaches
answered for an arbitrary email.
All nine now return 42501. The logged-out roster still works.
Revokes are computed from the catalog rather than a hand-written list, so a
function added later without its own REVOKE is still covered. Grants are then
re-applied deliberately; docs/grant-matrix.md states the intent per role per
object.
has_coach_access additionally short-circuits when auth.uid() is set and does
not match p_user_id, so a signed-in member cannot probe anyone else. Its
entitlement logic is otherwise byte-for-byte the original -- the free-tier
metering, trialing/grace_period statuses and creator-owns-their-coach branches
all matter and are preserved.
Also drops four legacy phone-based policies on coach_profiles. An RLS predicate
runs as the invoking role, so their subquery into user_profiles forced every
reader of coach_profiles -- including anon -- to hold SELECT on user_profiles.
That is the root cause of the four rls-probe failures inherited on main, and
the tempting fix would have exposed every member's email and phone to the
internet. They are redundant with the uid/email policies.
rls-probe.mjs goes 44/4 -> 77/0, with 29 negative assertions including a drift
detector that fails if a future SECURITY DEFINER function is left PUBLIC.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #25.
Not another instance — the class. Postgres grants
EXECUTEon every new function toPUBLICby default, so theGRANT ... TO service_rolesitting next to aSECURITY DEFINERfunction excluded nobody.What was actually reachable
Verified on a from-scratch stack over PostgREST using only the anon key — the key that ships in the web bundle. Every one of these returned
200:due_coach_nudges(int)user_id,display_name, coach name and discipline. Noauth.uid()filter exists in it at all — it was written as a service_role dispatcher query.consume_free_message(uuid,uuid)messages_usedthree times over three unauthenticated calls, exhausting a member's free tier.has_coach_access(uuid,uuid)truefor an arbitrary member id — and the id needed to drive it leaks fromdue_coach_nudges.check_subscription_access,get_trial_days_remaining,can_publish_coachescreate_user_with_trial,get_or_create_userINSERT.Functions whose bodies are
auth.uid()-scoped (release_push_deviceand friends) were callable but no-opped —release_push_devicereturned204and changed nothing. Revoked anyway: "the body happens to check" is not an access control.All nine now return
42501.get_coach_rosterstill works logged-out.Approach
Revokes are computed from the catalog, not a hand-written list, so a function added by a later migration that forgets its own
REVOKEis still covered on the next deploy. Grants are then re-applied deliberately.security_definer_grant_audit()returns anySECURITY DEFINERfunction still reachable byPUBLIC/anon, minus a two-entry documented allowlist.rls-probe.mjsasserts it is empty, so the next one to forget fails the probes instead of shipping.has_coach_accessalso short-circuits whenauth.uid()is set and doesn't matchp_user_id. Its entitlement logic is otherwise unchanged — the free-tier metering,trialing/grace_periodstatuses and creator-owns-their-coach branches are all preserved. (My first draft of this rewrite dropped all three; caught before it applied.)The four inherited probe failures
mainwas at 44/4, all four failing withpermission denied for table user_profileson the coach detail query.Root cause: four legacy phone-based policies on
coach_profilesthat subqueryuser_profiles. An RLS predicate is evaluated as the invoking role, so their existence forced every reader ofcoach_profiles— including anon — to hold SELECT onuser_profiles.The tempting fix, granting anon SELECT on
user_profiles, would have published every member's email, phone, timezone and reference photo URL. Dropped the policies instead; they're redundant with the uid/email policies the multi-domain work added.Verification
rls-probe.mjs44/4 → 77/0, with 29 new negative assertions: per-function42501checks, per-table anon denials,creator_profilespayout columns unreadable by anon, and cross-member entitlement probing blocked.supabase-shim.sqlwithON_ERROR_STOP=1.tsc --noEmitclean,node --checkclean on every function,terraform validatepasses.Note for the next agent
viz-realtime-probe.mjsis flaky — it failed 29/4 on the first run and passed 33/0 on a re-run with no changes in between. The failures are all "the message arrived over realtime — received 0", i.e. the websocket subscription hadn't established before the assertion. Worth a retry/wait rather than an hour of debugging.docs/grant-matrix.mdis the reviewable per-role-per-object table #25 asks for.🤖 Generated with Claude Code