Skip to content

Commit 0331361

Browse files
MaxBrychclaude
andcommitted
docs(plans): lockdown amendment — no anon function takes a wallet as authorization; creation and invite reads move behind the signature
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent df25f38 commit 0331361

1 file changed

Lines changed: 17 additions & 49 deletions

File tree

docs/superpowers/plans/2026-07-31-workspace-ga-security-lockdown.md

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ git commit -m "feat(web): org-membership message spec — the signed contract fo
132132
- Create: `supabase/migrations/20260801_account_membership_lockdown.sql`
133133

134134
**Interfaces:**
135-
- Produces RPCs: `create_account_with_owner(p_wallet text, p_account_type text, p_name text, p_sub_type text default null, p_bio text default null, p_avatar_url text default null) returns accounts`, `get_invite_by_token(p_token text) returns invite_tokens`, `list_pending_invites(p_account_id uuid, p_wallet text) returns setof invite_tokens`, `has_pending_invite(p_account_id uuid, p_wallet text) returns boolean`. Tasks 4 and 6 call these via `supabase.rpc(...)`.
135+
- Produces ONE anon-callable RPC: `get_invite_by_token(p_token text) returns invite_tokens` (Tasks 5/6 call it via `supabase.rpc(...)`). Account creation and every other invite read are edge-function actions (Task 3) — NOT RPCs.
136136

137-
Design notes baked in: `create_account_with_owner` only ever creates a NEW account with `p_wallet` as its first owner — it cannot touch an existing org, so the trusted-wallet posture is safe here. `get_invite_by_token` makes knowledge-of-token the credential (closes the enumeration hole that `invite_tokens_select USING (true)` is today). Everything else becomes deny-by-default; the edge function writes with service role.
137+
Design notes (amended after task review, 2026-07-31): the v1 draft granted `create_account_with_owner`, `list_pending_invites` and `has_pending_invite` to anon with a client-supplied `p_wallet` — the reviewer showed that leaks bearer tokens (owner wallets are public in `account_owners`, so anyone can impersonate an owner parameter) and lets anon attach any registered wallet as owner of a new account. **Rule: no anon-granted function may take a wallet parameter as an authorization input.** `get_invite_by_token` survives because knowledge-of-token IS the credential (closes the enumeration hole that `invite_tokens_select USING (true)` is today). Everything else is deny-by-default; the signature-verified edge function reads/writes with service role.
138138

139139
- [ ] **Step 1: Write the migration**
140140

@@ -146,58 +146,21 @@ Design notes baked in: `create_account_with_owner` only ever creates a NEW accou
146146
-- edge function (service role).
147147
-- ⚠️ APPLY ONLY AFTER the org-membership edge function and rewired clients are live.
148148

149-
-- ── RPCs ────────────────────────────────────────────────────────────────────
150-
create or replace function public.create_account_with_owner(
151-
p_wallet text, p_account_type text, p_name text,
152-
p_sub_type text default null, p_bio text default null, p_avatar_url text default null
153-
) returns accounts
154-
language plpgsql security definer set search_path = public, pg_temp as $$
155-
declare v_account accounts;
156-
begin
157-
if p_account_type not in ('personal','organisation') then
158-
raise exception 'invalid account_type' using errcode = '22023';
159-
end if;
160-
insert into accounts (account_type, name, sub_type, bio, avatar_url)
161-
values (p_account_type, p_name, p_sub_type, p_bio, p_avatar_url)
162-
returning * into v_account;
163-
insert into account_owners (account_id, wallet_address, role)
164-
values (v_account.id, lower(p_wallet), 'owner');
165-
return v_account;
166-
end $$;
167-
149+
-- ── RPC ─────────────────────────────────────────────────────────────────────
150+
-- The ONLY anon-callable function. Knowledge of the token is the credential
151+
-- (bearer semantics); it takes no wallet parameter. All other membership
152+
-- reads/writes go through the signature-verified org-membership edge function
153+
-- (service role). Rule: no anon-granted function may take a wallet parameter
154+
-- as an authorization input — owner wallets are public in account_owners, so
155+
-- such a parameter is attacker-controlled.
168156
create or replace function public.get_invite_by_token(p_token text)
169157
returns invite_tokens
170158
language sql security definer set search_path = public, pg_temp stable as $$
171159
select * from invite_tokens where token = p_token limit 1;
172160
$$;
173161

174-
create or replace function public.list_pending_invites(p_account_id uuid, p_wallet text)
175-
returns setof invite_tokens
176-
language sql security definer set search_path = public, pg_temp stable as $$
177-
select i.* from invite_tokens i
178-
where i.account_id = p_account_id and i.status = 'pending'
179-
and exists (select 1 from account_owners o
180-
where o.account_id = p_account_id
181-
and lower(o.wallet_address) = lower(p_wallet)
182-
and o.role in ('owner','admin'));
183-
$$;
184-
185-
create or replace function public.has_pending_invite(p_account_id uuid, p_wallet text)
186-
returns boolean
187-
language sql security definer set search_path = public, pg_temp stable as $$
188-
select exists (select 1 from invite_tokens
189-
where account_id = p_account_id and status = 'pending'
190-
and lower(coalesce(invited_wallet,'')) = lower(p_wallet));
191-
$$;
192-
193-
revoke all on function public.create_account_with_owner(text,text,text,text,text,text) from public;
194162
revoke all on function public.get_invite_by_token(text) from public;
195-
revoke all on function public.list_pending_invites(uuid,text) from public;
196-
revoke all on function public.has_pending_invite(uuid,text) from public;
197-
grant execute on function public.create_account_with_owner(text,text,text,text,text,text) to anon, authenticated;
198163
grant execute on function public.get_invite_by_token(text) to anon, authenticated;
199-
grant execute on function public.list_pending_invites(uuid,text) to anon, authenticated;
200-
grant execute on function public.has_pending_invite(uuid,text) to anon, authenticated;
201164

202165
-- ── Policy lockdown ─────────────────────────────────────────────────────────
203166
-- accounts: reads stay public; every write becomes service-role/RPC only.
@@ -242,6 +205,11 @@ git commit -m "feat(db): membership lockdown migration — RPCs in, USING(true)
242205
- `leave` `{ accountId }` — deletes signer's own `account_owners` row; refuses when signer is the LAST owner (`role='owner'` count would drop to 0).
243206
- `remove_member` `{ accountId, memberWallet }` — signer owner/admin; cannot remove an `owner` unless signer is `owner`; deletes the row.
244207
- `update_account` `{ accountId, updates }` — signer owner/admin; `updates` filtered to `name, bio, avatar_url, cover_url, contact_email, opening_hours`; stamps `updated_at`.
208+
- `create_account` `{ accountType: "personal"|"organisation", name, subType?, bio?, avatarUrl? }` — creates the account and inserts the SIGNER (never a passed wallet) as its first `owner` row, atomically (insert account, then owner; on owner-insert failure delete the account row). Validate: `name` 1–80 chars after trim, `bio` ≤ 500 chars, `accountType` in the two values, `subType` (when set) in `('restaurant','unternehmen','verein','stadt','fraktion','journalist')`. Returns the account row. (Amended 2026-07-31: replaces the withdrawn `create_account_with_owner` RPC — creation requires the creator's signature so no one can attach a stranger's wallet as owner.)
209+
- `list_invites` `{ accountId }` — signer owner/admin of `accountId`; returns pending invite rows (incl. tokens — the signer is entitled to them). (Amended: replaces the withdrawn `list_pending_invites` RPC.)
210+
- `has_pending_invite` `{ accountId }` — returns `{ pending: boolean }` for the SIGNER's wallet only. (Amended: replaces the withdrawn RPC.)
211+
212+
Extend the `ACTIONS` array and `OrgAction` union (here and in Task 1's module) with `create_account`, `list_invites`, `has_pending_invite` — the Task 1 test file is the contract; update it in the same commit as the module.
245213

246214
- [ ] **Step 1: Implement the function.** Skeleton (verify + dispatch; each handler is a small service-role query following the checks above — write them all, they are listed exhaustively in the Interfaces block):
247215

@@ -337,7 +305,7 @@ it("requestBody signs the canonical message and echoes fields", async () => {
337305
- [ ] **Step 2: Run to verify fail**`pnpm test:web` → FAIL (no `requestBody`).
338306

339307
- [ ] **Step 3: Implement `client.ts`**`requestBody(account, action, payload, timestampSec = Math.floor(Date.now()/1000))` builds `{action, wallet, timestampSec, payload, signature}` via `buildOrgMessage` + `account.signMessage`; `callOrgMembership` wraps it in `fetch`. Then rewire `supabase-accounts.ts`:
340-
- `createPersonalAccount` / `createOrgAccount`: replace the two-step insert (:163–177 + :187–190) with `supabase.rpc("create_account_with_owner", { p_wallet, p_account_type, p_name, p_sub_type, p_bio, p_avatar_url })` (returns the account row; keep the existing return shapes).
308+
- `createPersonalAccount(account, …)` / `createOrgAccount(account, …)`: replace the two-step insert (:163–177 + :187–190) with `callOrgMembership(account, "create_account", { accountType, name, subType, bio, avatarUrl })` (amended 2026-07-31 — creation is signature-verified; the signer becomes first owner; keep the existing return shapes by re-fetching or using the returned row). Update the call chains: `apps/web/src/lib/supabase-users.ts:87` (first-login personal account — the thirdweb account object is in scope there) and `AccountContext.createOrgAccount` (`apps/web/src/lib/context/AccountContext.tsx:153,161`).
341309
- `updateAccount(account, accountId, updates)`: `callOrgMembership(account, "update_account", { accountId, updates })`, then re-fetch the row via the (still open) `accounts_select` for the return value.
342310
- `removeOwner(account, accountId, wallet)`: `callOrgMembership(account, "remove_member", { accountId, memberWallet: wallet })`.
343311
- Delete `inviteOwner` entirely (it is the bypass path finding §1 warns about); update `AccountContext.tsx` accordingly.
@@ -361,7 +329,7 @@ git commit -m "fix(web): account creation moves to the atomic RPC, account updat
361329

362330
**Interfaces:**
363331
- Consumes: Task 4's `callOrgMembership`; Task 2 RPCs.
364-
- Produces (changed signatures, expo mirrors them in Task 6): `createInAppInvite(account, accountId, invitedWallet, role, expiresInDays?)`, `createLinkInvite(account, accountId, role, expiresInDays?)`, `acceptInvite(account, inviteId)`, `declineInvite(account, inviteId)`, `revokeInvite(account, inviteId)`, `leaveOrg(account, accountId)``invitedBy` is now derived server-side from the verified signer, so the parameter disappears. Reads: `fetchInviteByToken(token)``supabase.rpc("get_invite_by_token", { p_token })`, `fetchPendingInvites(accountId, wallet)``rpc("list_pending_invites", …)`, `hasPendingInvite(accountId, wallet)``rpc("has_pending_invite", …)`.
332+
- Produces (changed signatures, expo mirrors them in Task 6): `createInAppInvite(account, accountId, invitedWallet, role, expiresInDays?)`, `createLinkInvite(account, accountId, role, expiresInDays?)`, `acceptInvite(account, inviteId)`, `declineInvite(account, inviteId)`, `revokeInvite(account, inviteId)`, `leaveOrg(account, accountId)``invitedBy` is now derived server-side from the verified signer, so the parameter disappears. Reads (amended 2026-07-31): `fetchInviteByToken(token)``supabase.rpc("get_invite_by_token", { p_token })` (the one anon RPC); `fetchPendingInvites(account, accountId)``callOrgMembership(account, "list_invites", { accountId })`; `hasPendingInvite(account, accountId)``callOrgMembership(account, "has_pending_invite", { accountId })` (answers for the signer only).
365333

366334
- [ ] **Step 1: Rewire each export** to the contract above (writes → `callOrgMembership`; reads → RPCs). Update every call site the exploration listed: `apps/web/src/app/invite/[token]/page.tsx:67,80` plus any `fetchPendingInvites` dashboards (`git grep -n "fetchPendingInvites\|createInAppInvite\|createLinkInvite" apps/web/src` and fix all hits).
367335
- [ ] **Step 2: Verify**`pnpm test:web` still green; `git grep -n 'from("invite_tokens")' apps/web/src` returns ZERO hits (every touchpoint goes through RPC/edge fn now); same for `.from("account_owners").insert` and `.from("accounts").update`.
@@ -612,4 +580,4 @@ git commit -m "docs: findings 1, 2 and 4 close — the workspace launch gate lif
612580

613581
- **Spec coverage:** W0 = Tasks 1–8 + 13 (findings §1 §2 §4, invite corollary; offsite backups + firewall stay user one-liners, listed in Task 13). W1 = Tasks 9–12 + 13 (role-based write, mobile route, flag flip). AI-Act disclosure: already shipped 2026-07-30 (parallel session) — excluded on purpose.
614582
- **Ordering constraint restated:** Tasks 3–7 MUST be live in production before the Task 2 migration is applied. The plan encodes this by deferring application to Task 13.
615-
- **Known accepted risks:** (1) old Expo builds break on membership writes after the lockdown until the EAS build ships — coordinated in Task 13; (2) `create_account_with_owner` keeps the repo's trusted-wallet posture for NEW accounts only (documented in Task 2); (3) `list_pending_invites` is read-only trusted-wallet (mild exposure: invited wallets/roles of one org).
583+
- **Known accepted risks:** (1) old Expo builds break on membership writes after the lockdown until the EAS build ships — coordinated in Task 13; (2) `get_invite_by_token` is bearer-semantics by design — anyone holding a link token can read that one invite row (that IS the link-invite product behavior). (Amendment 2026-07-31: the v1 trusted-wallet RPCs `create_account_with_owner` / `list_pending_invites` / `has_pending_invite` were withdrawn after task review showed the wallet parameter is attacker-controlled; those flows are signature-verified edge-fn actions now.)

0 commit comments

Comments
 (0)