ExplorerPrototypes

GIK Marketplace · data model

This is a map of the GIK data: what gets stored and how the pieces connect. Start with the spine, then pick a domain to see its tables.

The spine — seven stages

  1. 1. Onboardwho is this orgWho is this organization, and which side of the exchange are they on?organization, profile, organization_member, onboarding_submission, join_policy
  2. 2. Declareneed and supplyWhat does an NPO need, and what can a supplier give?need, resource, need_resource, resource_import, disaster
  3. 3. Qualifywho is eligibleWho is eligible for what, under which constraints?organization_restriction, resource_restriction, restriction_type
  4. 4. Matchwhat answers whatWhich resources answer which needs, and how well?match_run, match_score, search gate, grade
  5. 5. Offera supplier commitsWhich supplier commits to which need?offer, offer_item, offer_status, offer_source
  6. 6. Fulfillgoods moveThe promise becomes goods in motion.cart, order, order_item, order_transport_details
  7. 7. Accountwhat happenedWhat actually happened, and who saw it?activity_event, notification, activity_visibility
StageThe question it answersCore concepts
1OnboardWho is this organization, and which side of the exchange are they on?organization, profile, organization_member, onboarding_submission, join_policy
2DeclareWhat does an NPO need, and what can a supplier give?need, resource, need_resource, resource_import, disaster
3QualifyWho is eligible for what, under which constraints?organization_restriction, resource_restriction, restriction_type
4MatchWhich resources answer which needs, and how well?match_run, match_score, search gate, grade
5OfferWhich supplier commits to which need?offer, offer_item, offer_status, offer_source
6FulfillThe promise becomes goods in motion.cart, order, order_item, order_transport_details
7AccountWhat actually happened, and who saw it?activity_event, notification, activity_visibility

The twelve domains — one Postgres schema (public); domains are a decided grouping, not a database boundary

identity2 tables
The people who use GIK, and how they like to be contacted. Each person's profile and their notification settings.
View ERD →
organizations8 tables
The nonprofits and suppliers that take part, where they operate, and who belongs to each. Each organization, its locations, members, invitations, partners, followers, verified web domains, and any limits on who it may deal with.
View ERD →
resources5 tables
What a supplier can give away, and on what terms. Each listed item, its photos, who may claim it, and the spreadsheets suppliers upload to list many items at once.
View ERD →
needs3 tables
What a nonprofit is asking for, and how urgently. Each request, the items and quantities on it, its photos, and the disaster it answers.
View ERD →
matching2 tables
GIK's automatic search for supplies that could answer a need. Each search run and the scores it gave; a good score is not yet an offer.
View ERD →
offers2 tables
A supplier's promise to give specific items toward a nonprofit's need. Each offer, the items on it, and whether it is waiting, accepted, declined, or cancelled.
View ERD →
orders5 tables
Goods on the move, what was agreed, who ships it, and where it is going. Carts, orders, the items on them, and the pickup and transport details for each.
View ERD →
disasters2 tables
The events that make a need urgent, such as a hurricane or a flood. Each disaster, when it happened, and the FEMA details GIK keeps up to date.
View ERD →
auth6 tables
How a partner's software proves who it is when it talks to GIK. API keys, connected apps, their sign-in tokens, and a log of every call they make.
View ERD →
webhooks2 tables
The messages GIK sends to a partner's software when something happens. What each partner asked to be told about, and every delivery attempt and whether it landed.
View ERD →
notifications2 tables
What GIK tells a person, and by which channel. Each notice, who received it, how it was sent, and whether it has been read.
View ERD →
platform4 tables
Shared record-keeping the rest of GIK leans on. The activity log, uploaded files, sign-up requests from people without an account yet, and app settings.
View ERD →

Notes and recommendations

Findings from reading the schema as decided, not the database as deployed. Each one was verified against the migration SQL this explorer is generated from, and the constraint — or its absence — is cited so you can check the claim rather than take it on trust. Re-verified 2026-09-03 against gik-platform develop at 843dfa1b, the snapshot master schema 1.1 reads; every count below is from that SQL.

Ordered by what we would fix first. The first three are the ones we would raise in a meeting; the rest are worth a ticket each.

1. Every profile, including email, is readable by the anonymous role

profiles grants full table privileges to anon, and its SELECT policy carries no role restriction and no predicate. The two statements, verbatim:

  • GRANT ALL ON TABLE "public"."profiles" TO "anon";
  • CREATE POLICY "auth" ON "public"."profiles" FOR SELECT USING (true);

A policy written without a TO clause applies to PUBLIC — every role, including anon. Combined with USING (true), any caller holding the project's anonymous key can read every profile row, email included. Nothing about this requires an account.

It is worse for deleted users. profiles uses tombstones rather than deletion: the visible PII columns are anonymised at delete time, and the original values are stashed in deleted_metadata on the same row. RLS is row-level, not column-level, so that column is readable under the very same policy. A user who deletes their account keeps their real name and email in a field the anonymous role can read.

Recommendation. Decide what a profile is meant to expose publicly. If the answer is a display name and avatar — which is all the "resolve a name for attribution" argument actually needs — expose those columns through a view, or scope the policy TO authenticated and serve public attribution from a narrower surface. deleted_metadata should not be reachable through a row policy at all; move it somewhere only service_role can read.

2. Eligibility is not enforced anywhere in the database

organization_restrictions and resource_restrictions describe who may receive what. Nothing in the database makes those rows binding: there is no CHECK constraint, trigger, RLS policy, or function anywhere in the schema whose definition mentions eligibility. The restriction tables do carry RLS, but it governs who may read and write restriction rows — not whether a restriction is honoured when an offer or order is created.

The only enforcement is client-side, in application code, and it checks a single field. A direct API call, a service-role write, or a bug in one screen bypasses the rules entirely, and the database will accept the row.

Recommendation. Decide deliberately whether eligibility is a rule or a hint. If it is a rule it needs a database-layer gate — a trigger, or an RPC all writes go through — because a client-side check is unenforceable by construction. If it is a hint, say so in the schema documentation so the next engineer does not assume protection that is not there. Either answer is fine; the current state is that the model implies enforcement it does not have.

The 1.1 schema shows what the alternative looks like in this same codebase. The unlisted rule on resources (GIK-336) is held by two triggers that refuse or correct the write wherever it comes from, and create_offer_with_items re-validates inside its transaction what RLS cannot see, because RLS gates rows and not the relationships between them. Eligibility has none of that, and the new partner reads widen the gap: the category, state, disaster and faith filters that search_need_items_by_embedding and portfolio_matches_for_org accept are the partner's own choices, not the restriction rows anyone declared, so a supplier's portfolio can rank a need from an NPO its own resource_restrictions say may not receive from it.

3. Six SECURITY DEFINER functions do not pin search_path

Of 39 SECURITY DEFINER functions, 33 set search_path and six do not:

  • matching.enqueue_match_run
  • matching.process_pending_match_runs
  • needs.create_need_with_items
  • onboarding_rpc.stamp_email_on_session
  • onboarding_rpc.lookup_organization_by_domain
  • profiles.handle_auth_user_update

The three added since 1.0 — ensure_match_worker_secret, close_unlisted_resource_on_offer_close and search_need_items_by_embedding — all pin it, so these are the same six as before.

Such a function runs with its owner's privileges and bypasses the caller's RLS. Without a pinned search_path, an unqualified name inside it resolves against whatever the caller's search path happens to be — the standard privilege- escalation shape for this class of function.

Recommendation. Add SET search_path = '', or an explicit schema list, to all six, matching the thirty-three that already do it. Additive and low risk.

4. Three tables are fully readable by every signed-in user

Each of these is TO authenticated USING (true) — any authenticated account reads every row, not only the rows relating to them:

  • organizations — the full organisation directory.
  • organization_restrictions — every organisation's eligibility rules.
  • organization_members — every membership in the system. The policy is named Users can view their own memberships, which is not what it does; the name describes an intent the predicate does not implement.

This may be deliberate for a marketplace where participants need to see one another. It is worth confirming, because the membership policy's name suggests at least one of the three was not.

Recommendation. Confirm each is intended, and rename the membership policy to match its behaviour whichever way you decide. A policy whose name contradicts its predicate is a trap for the next person auditing this.

5. organization_partners.status defaults to a value its own CHECK rejects

The column defaults to 'active'. Its CHECK admits only pending, accepted, and rejected. A plain INSERT relying on the default therefore fails outright.

This stays invisible only because the INSERT policy independently forces every application-written row to carry an explicit pending or accepted. It surfaces the moment anything inserts without naming a status — a migration, a backfill, a service-role script.

Recommendation. Change the default to 'pending', matching the sibling organization_invites.status, which already defaults that way.

6. Two foreign keys point at Supabase auth where every sibling points at profiles

The naming is inverted in both directions, which makes this easy to miss:

columnactually references
oauth_authorization_codes.user_idpublic.profiles
activity_events.user_idpublic.profiles
organization_partners.created_by_profile_idauth.users
organization_partners.responded_by_profile_idauth.users

Every other actor column targets public.profilescreated_by (seven of them), uploaded_by, triggered_by, invited_by_profile_id, deleted_by, converted_by_profile_id, and a created_by_profile_id on a different table that resolves correctly.

The join still works, because a profile's primary key is its auth user's id. But the constraint does not require a profile row to exist, and its ON DELETE SET NULL fires when the auth user is deleted — while profiles deliberately uses tombstones rather than deletion. So these two columns follow a different lifecycle from the rest of the schema.

Recommendation. Repoint both at public.profiles. The column names already say that is what they meant.

7. Three tables permit duplicates the model treats as impossible

None has a unique index, so the database will accept a second row where the domain expects one:

  • organization_followers — nothing prevents the same follow twice. organizations.followers_count, which this table is meant to summarise, is only as accurate as whatever recomputes it.
  • organization_partners — nothing prevents two organisations accumulating more than one partnership row. A CHECK bars an org from partnering with itself; nothing bars a duplicate pair.
  • organization_locations.is_primary — a plain boolean DEFAULT false NOT NULL with no guard. Two locations on one organisation can both claim primary.

Recommendation. Add a unique index to each: the follow pair, the partner pair, and a partial unique on (organization_id) WHERE is_primary. All three are additive and ship without application changes — though each needs a duplicate sweep first.

What the schema gets right

Worth stating, because these are the parts most often wrong:

  • RLS is enabled on all 43 tables. No table is left ungoverned.
  • api_keys uses column-level grants rather than the project-wide GRANT ALL, deliberately and with a comment explaining why, so the secret material is not reachable through the table grant.
  • Membership and admin checks are re-evaluated per query through helper functions rather than read from a cached permission, so a stale role row cannot grant access on its own.
  • The unlisted invariant is enforced at the database boundary (1.1). Two triggers on resources refuse an unlisted row from anything but the offer path, refuse a catalog row becoming unlisted or an unlisted one becoming public, and hold a closed one closed; create_offer_with_items is SECURITY INVOKER, so RLS still governs every row it writes, and it re-checks membership, need ownership, self-offer and resource ownership inside the transaction. This is the pattern item 2 asks for.

A note on what this document is

These are observations about the schema, drawn from the migrations. They are not a penetration test, and they say nothing about the application code above the database or the project's Supabase settings, where some of these may already be handled. Items 1 and 2 are the ones we would ask about first: item 1 because it exposes data today without an account, item 2 because the model implies an enforcement it does not have.

Data model · gik-master-schema.md v1.1 · gik-ontology.md v1.2 · generated 2026-09-11