@marktiderman/genesis-supabase 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +523 -1
- package/dist/blocks.d.ts +19 -0
- package/dist/blocks.js +3 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-FAA2FTF4.js +110 -0
- package/dist/chunk-FAA2FTF4.js.map +1 -0
- package/dist/chunk-T2HGFC4C.js +37 -0
- package/dist/chunk-T2HGFC4C.js.map +1 -0
- package/dist/index-BSGz3RGk.d.ts +317 -0
- package/dist/index.d.ts +4 -78
- package/dist/index.js +63 -2
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +233 -5
- package/dist/native.js +196 -1
- package/dist/native.js.map +1 -1
- package/functions/genesis-apple-revoke/apple.ts +237 -0
- package/functions/genesis-apple-revoke/handler.ts +227 -0
- package/functions/genesis-apple-revoke/index.ts +55 -0
- package/functions/genesis-push-send/expo.ts +117 -0
- package/functions/genesis-push-send/handler.ts +462 -0
- package/functions/genesis-push-send/index.ts +54 -0
- package/functions/genesis-support-cleanup/handler.ts +80 -0
- package/functions/genesis-support-cleanup/index.ts +39 -0
- package/functions/genesis-support-intake/contract.ts +193 -0
- package/functions/genesis-support-intake/handler.ts +170 -0
- package/functions/genesis-support-intake/index.ts +47 -0
- package/functions/genesis-support-notify/handler.ts +167 -0
- package/functions/genesis-support-notify/index.ts +15 -0
- package/functions/tests/genesis-apple-revoke_test.ts +81 -0
- package/functions/tests/genesis-push-send_test.ts +53 -0
- package/migrations/0001_genesis_account_deletion.sql +133 -0
- package/migrations/0002_genesis_app_release_policy.sql +82 -0
- package/migrations/0003_genesis_apple_token_revocation.sql +205 -0
- package/migrations/0004_genesis_tenant_features.sql +333 -0
- package/migrations/0005_genesis_push.sql +524 -0
- package/migrations/0006_genesis_support.sql +846 -0
- package/migrations/tests/0001_genesis_account_deletion.test.sql +138 -0
- package/migrations/tests/0002_genesis_app_release_policy.test.sql +148 -0
- package/migrations/tests/0003_genesis_apple_token_revocation.test.sql +230 -0
- package/migrations/tests/0004_genesis_tenant_features.test.sql +418 -0
- package/migrations/tests/0005_genesis_push.test.sql +407 -0
- package/migrations/tests/0006_genesis_support.test.sql +552 -0
- package/package.json +92 -4
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
-- pgTAP test for 0001_genesis_account_deletion.sql.
|
|
2
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
3
|
+
-- It reads only its own users and tables and rolls everything back. If your
|
|
4
|
+
-- app already has `private.genesis_before_account_delete`, the hook cases
|
|
5
|
+
-- replace it inside this transaction only.
|
|
6
|
+
--
|
|
7
|
+
-- Signing in: auth.uid() reads request.jwt.claims (and the legacy
|
|
8
|
+
-- request.jwt.claim.sub on older images), so each case sets both, then
|
|
9
|
+
-- switches to the authenticated role.
|
|
10
|
+
begin;
|
|
11
|
+
create extension if not exists pgtap with schema extensions;
|
|
12
|
+
select plan(20);
|
|
13
|
+
|
|
14
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
15
|
+
select ('00000000-0000-4000-8000-0000000000' || n)::uuid, 'authenticated', 'authenticated', 'genesis-delete-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
16
|
+
from unnest(array['a1', 'b2', 'c3', 'd4', 'e5', 'f6', '07']) n;
|
|
17
|
+
|
|
18
|
+
create function pg_temp.exists_user(n text) returns boolean language sql as $$
|
|
19
|
+
select exists (select 1 from auth.users where id = ('00000000-0000-4000-8000-0000000000' || n)::uuid)
|
|
20
|
+
$$;
|
|
21
|
+
|
|
22
|
+
-- Shape and privileges -------------------------------------------------------
|
|
23
|
+
select ok(
|
|
24
|
+
(select prosecdef from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
25
|
+
'delete_my_account is security definer'
|
|
26
|
+
);
|
|
27
|
+
select ok(
|
|
28
|
+
(select proconfig @> array['search_path=""'] from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
29
|
+
'delete_my_account pins an empty search_path'
|
|
30
|
+
);
|
|
31
|
+
select is(
|
|
32
|
+
(select proowner::regrole::text from pg_proc where oid = 'public.delete_my_account()'::regprocedure),
|
|
33
|
+
'postgres',
|
|
34
|
+
'delete_my_account is owned by postgres, as hosted migrations run'
|
|
35
|
+
);
|
|
36
|
+
select ok(not has_function_privilege('anon', 'public.delete_my_account()', 'execute'), 'anon cannot execute it');
|
|
37
|
+
select ok(
|
|
38
|
+
not exists (
|
|
39
|
+
select 1 from pg_proc p, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
40
|
+
where p.oid = 'public.delete_my_account()'::regprocedure and a.grantee = 0 and a.privilege_type = 'EXECUTE'
|
|
41
|
+
),
|
|
42
|
+
'public has no execute grant'
|
|
43
|
+
);
|
|
44
|
+
select ok(has_function_privilege('authenticated', 'public.delete_my_account()', 'execute'), 'authenticated can execute it');
|
|
45
|
+
select ok(
|
|
46
|
+
not has_function_privilege('authenticated', 'private.genesis_require_recent_sign_in(interval)', 'execute'),
|
|
47
|
+
'authenticated cannot call the recent-sign-in helper directly'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
-- Deletes only the caller ------------------------------------------------------
|
|
51
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
|
52
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000a1', true);
|
|
53
|
+
set local role authenticated;
|
|
54
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a signed-in person deletes their account');
|
|
55
|
+
reset role;
|
|
56
|
+
select ok(not pg_temp.exists_user('a1') and pg_temp.exists_user('b2'), 'only the caller''s login is gone');
|
|
57
|
+
|
|
58
|
+
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
|
|
59
|
+
select set_config('request.jwt.claim.sub', '', true);
|
|
60
|
+
set local role authenticated;
|
|
61
|
+
select throws_ok($$ select public.delete_my_account() $$, '42501', null, 'a call with no signed-in person is refused');
|
|
62
|
+
reset role;
|
|
63
|
+
|
|
64
|
+
-- Foreign keys -------------------------------------------------------------------
|
|
65
|
+
create table public.genesis_test_kept (person uuid references auth.users (id));
|
|
66
|
+
create table public.genesis_test_cascaded (person uuid references auth.users (id) on delete cascade);
|
|
67
|
+
insert into public.genesis_test_kept values ('00000000-0000-4000-8000-0000000000c3');
|
|
68
|
+
insert into public.genesis_test_cascaded values ('00000000-0000-4000-8000-0000000000d4');
|
|
69
|
+
|
|
70
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
|
71
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000c3', true);
|
|
72
|
+
set local role authenticated;
|
|
73
|
+
select throws_ok($$ select public.delete_my_account() $$, '23503', null, 'a row without on delete cascade refuses the delete');
|
|
74
|
+
reset role;
|
|
75
|
+
select ok(pg_temp.exists_user('c3'), '... and the login is kept');
|
|
76
|
+
|
|
77
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000d4","role":"authenticated"}', true);
|
|
78
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000d4', true);
|
|
79
|
+
set local role authenticated;
|
|
80
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a person with cascading rows deletes their account');
|
|
81
|
+
reset role;
|
|
82
|
+
select is((select count(*) from public.genesis_test_cascaded)::int, 0, '... and their cascading rows go with it');
|
|
83
|
+
|
|
84
|
+
-- MFA step-up -------------------------------------------------------------------
|
|
85
|
+
insert into auth.mfa_factors (id, user_id, friendly_name, factor_type, status, created_at, updated_at)
|
|
86
|
+
values (gen_random_uuid(), '00000000-0000-4000-8000-0000000000e5', 'genesis-test', 'totp', 'verified', now(), now());
|
|
87
|
+
|
|
88
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000e5","role":"authenticated","aal":"aal1"}', true);
|
|
89
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000e5', true);
|
|
90
|
+
set local role authenticated;
|
|
91
|
+
select throws_ok($$ select public.delete_my_account() $$, '42501', null, 'an aal1 session cannot delete an account with a verified factor');
|
|
92
|
+
reset role;
|
|
93
|
+
|
|
94
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000e5","role":"authenticated","aal":"aal2"}', true);
|
|
95
|
+
set local role authenticated;
|
|
96
|
+
select lives_ok($$ select public.delete_my_account() $$, 'an aal2 session can');
|
|
97
|
+
reset role;
|
|
98
|
+
|
|
99
|
+
-- The app hook -------------------------------------------------------------------
|
|
100
|
+
create or replace function private.genesis_before_account_delete(person uuid) returns void
|
|
101
|
+
language plpgsql set search_path = '' as $$
|
|
102
|
+
begin
|
|
103
|
+
if person = '00000000-0000-4000-8000-0000000000f6' then
|
|
104
|
+
raise exception 'sole owner';
|
|
105
|
+
end if;
|
|
106
|
+
if person = '00000000-0000-4000-8000-000000000007' then
|
|
107
|
+
perform private.genesis_require_recent_sign_in(interval '10 minutes');
|
|
108
|
+
end if;
|
|
109
|
+
end $$;
|
|
110
|
+
|
|
111
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000f6","role":"authenticated"}', true);
|
|
112
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000000f6', true);
|
|
113
|
+
set local role authenticated;
|
|
114
|
+
select throws_ok($$ select public.delete_my_account() $$, 'GA001', 'sole owner', 'a hook that raises refuses the delete, as GA001');
|
|
115
|
+
reset role;
|
|
116
|
+
select ok(pg_temp.exists_user('f6'), '... and the login is kept');
|
|
117
|
+
|
|
118
|
+
-- Recent sign-in, turned on from the hook: an hour-old sign-in is refused, a fresh one isn't.
|
|
119
|
+
select set_config('request.jwt.claims', json_build_object(
|
|
120
|
+
'sub', '00000000-0000-4000-8000-000000000007', 'role', 'authenticated',
|
|
121
|
+
'iat', extract(epoch from now())::bigint,
|
|
122
|
+
'amr', json_build_array(json_build_object('method', 'password', 'timestamp', extract(epoch from now() - interval '1 hour')::bigint))
|
|
123
|
+
)::text, true);
|
|
124
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000007', true);
|
|
125
|
+
set local role authenticated;
|
|
126
|
+
select throws_ok($$ select public.delete_my_account() $$, 'GA002', null, 'a refreshed token from an old sign-in is refused when the app requires a recent one');
|
|
127
|
+
reset role;
|
|
128
|
+
|
|
129
|
+
select set_config('request.jwt.claims', json_build_object(
|
|
130
|
+
'sub', '00000000-0000-4000-8000-000000000007', 'role', 'authenticated',
|
|
131
|
+
'amr', json_build_array(json_build_object('method', 'password', 'timestamp', extract(epoch from now() - interval '1 minute')::bigint))
|
|
132
|
+
)::text, true);
|
|
133
|
+
set local role authenticated;
|
|
134
|
+
select lives_ok($$ select public.delete_my_account() $$, 'a recent sign-in passes');
|
|
135
|
+
reset role;
|
|
136
|
+
|
|
137
|
+
select * from finish();
|
|
138
|
+
rollback;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
-- @feat:app-updates
|
|
2
|
+
-- pgTAP test for 0002_genesis_app_release_policy.sql.
|
|
3
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
4
|
+
-- It writes only its own `genesis-test-*` rows and rolls everything back.
|
|
5
|
+
begin;
|
|
6
|
+
create extension if not exists pgtap with schema extensions;
|
|
7
|
+
select plan(29);
|
|
8
|
+
|
|
9
|
+
-- Shape -------------------------------------------------------------------------
|
|
10
|
+
select has_table('public', 'app_release_policy', 'app_release_policy exists');
|
|
11
|
+
select col_is_pk('public', 'app_release_policy', 'app_id', 'one row per app');
|
|
12
|
+
select ok(
|
|
13
|
+
(select relrowsecurity from pg_class where oid = 'public.app_release_policy'::regclass),
|
|
14
|
+
'row level security is on'
|
|
15
|
+
);
|
|
16
|
+
select is(
|
|
17
|
+
(select array_agg(polname::text order by polname) from pg_policy where polrelid = 'public.app_release_policy'::regclass),
|
|
18
|
+
array['app_release_policy_read'],
|
|
19
|
+
'the only policy is the read policy'
|
|
20
|
+
);
|
|
21
|
+
select is(
|
|
22
|
+
(select polcmd::text from pg_policy where polrelid = 'public.app_release_policy'::regclass),
|
|
23
|
+
'r',
|
|
24
|
+
'... and it is for select'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Privileges ------------------------------------------------------------------------
|
|
28
|
+
select ok(has_table_privilege('anon', 'public.app_release_policy', 'select'), 'anon can read');
|
|
29
|
+
select ok(has_table_privilege('authenticated', 'public.app_release_policy', 'select'), 'authenticated can read');
|
|
30
|
+
select ok(
|
|
31
|
+
not has_table_privilege('anon', 'public.app_release_policy', 'insert')
|
|
32
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'update')
|
|
33
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'delete')
|
|
34
|
+
and not has_table_privilege('anon', 'public.app_release_policy', 'truncate'),
|
|
35
|
+
'anon cannot write'
|
|
36
|
+
);
|
|
37
|
+
select ok(
|
|
38
|
+
not has_table_privilege('authenticated', 'public.app_release_policy', 'insert')
|
|
39
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'update')
|
|
40
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'delete')
|
|
41
|
+
and not has_table_privilege('authenticated', 'public.app_release_policy', 'truncate'),
|
|
42
|
+
'authenticated cannot write'
|
|
43
|
+
);
|
|
44
|
+
select ok(
|
|
45
|
+
not exists (
|
|
46
|
+
select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
47
|
+
where c.oid = 'public.app_release_policy'::regclass and a.grantee = 0
|
|
48
|
+
),
|
|
49
|
+
'public has no grant'
|
|
50
|
+
);
|
|
51
|
+
select ok(
|
|
52
|
+
has_table_privilege('service_role', 'public.app_release_policy', 'insert')
|
|
53
|
+
and has_table_privilege('service_role', 'public.app_release_policy', 'update')
|
|
54
|
+
and has_table_privilege('service_role', 'public.app_release_policy', 'delete'),
|
|
55
|
+
'the service role can write'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- Writing and reading as each role ----------------------------------------------------------
|
|
59
|
+
set local role service_role;
|
|
60
|
+
select lives_ok(
|
|
61
|
+
$$ insert into public.app_release_policy (app_id, min_version_ios, min_version_android, store_url_ios, store_url_android, maintenance, maintenance_message, notes)
|
|
62
|
+
values ('genesis-test-a', '1.4.0', '1.4', 'https://apps.apple.com/app/id1', 'market://details?id=x', true, 'Back soon',
|
|
63
|
+
'[{"version": "1.4.0", "title": "Faster", "body": "Everything loads quicker."}]') $$,
|
|
64
|
+
'the service role writes a policy'
|
|
65
|
+
);
|
|
66
|
+
reset role;
|
|
67
|
+
|
|
68
|
+
set local role anon;
|
|
69
|
+
select is(
|
|
70
|
+
(select min_version_ios from public.app_release_policy where app_id = 'genesis-test-a'),
|
|
71
|
+
'1.4.0',
|
|
72
|
+
'anon reads it'
|
|
73
|
+
);
|
|
74
|
+
select throws_ok(
|
|
75
|
+
$$ insert into public.app_release_policy (app_id) values ('genesis-test-b') $$,
|
|
76
|
+
'42501', null, 'anon cannot insert'
|
|
77
|
+
);
|
|
78
|
+
select throws_ok(
|
|
79
|
+
$$ update public.app_release_policy set maintenance = false where app_id = 'genesis-test-a' $$,
|
|
80
|
+
'42501', null, 'anon cannot update'
|
|
81
|
+
);
|
|
82
|
+
reset role;
|
|
83
|
+
|
|
84
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
|
85
|
+
set local role authenticated;
|
|
86
|
+
select is(
|
|
87
|
+
(select maintenance from public.app_release_policy where app_id = 'genesis-test-a'),
|
|
88
|
+
true,
|
|
89
|
+
'authenticated reads it'
|
|
90
|
+
);
|
|
91
|
+
select throws_ok(
|
|
92
|
+
$$ delete from public.app_release_policy where app_id = 'genesis-test-a' $$,
|
|
93
|
+
'42501', null, 'authenticated cannot delete'
|
|
94
|
+
);
|
|
95
|
+
select throws_ok(
|
|
96
|
+
$$ update public.app_release_policy set min_version_ios = '0' where app_id = 'genesis-test-a' $$,
|
|
97
|
+
'42501', null, 'authenticated cannot update'
|
|
98
|
+
);
|
|
99
|
+
reset role;
|
|
100
|
+
|
|
101
|
+
-- Checks keep malformed values out (the client fails open on them anyway) -------------------
|
|
102
|
+
select throws_ok(
|
|
103
|
+
$$ insert into public.app_release_policy (app_id, min_version_ios, store_url_ios) values ('genesis-test-c', '1.4-beta', 'https://apps.apple.com/app/id1') $$,
|
|
104
|
+
'23514', null, 'a minimum version must be plain dotted numbers'
|
|
105
|
+
);
|
|
106
|
+
select throws_ok(
|
|
107
|
+
$$ insert into public.app_release_policy (app_id, min_version_android, store_url_android) values ('genesis-test-c', '1.2.3.4.5', 'market://details?id=x') $$,
|
|
108
|
+
'23514', null, '... of at most four parts'
|
|
109
|
+
);
|
|
110
|
+
select throws_ok(
|
|
111
|
+
$$ insert into public.app_release_policy (app_id, store_url_ios) values ('genesis-test-c', 'javascript:alert(1)') $$,
|
|
112
|
+
'23514', null, 'a store link must be https or the store''s scheme'
|
|
113
|
+
);
|
|
114
|
+
select throws_ok(
|
|
115
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '{"version": "1.0"}') $$,
|
|
116
|
+
'23514', null, 'notes must be an array'
|
|
117
|
+
);
|
|
118
|
+
select throws_ok(
|
|
119
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": 1, "body": "x"}]') $$,
|
|
120
|
+
'23514', null, 'each note needs a string version and body'
|
|
121
|
+
);
|
|
122
|
+
select throws_ok(
|
|
123
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": "1.0"}]') $$,
|
|
124
|
+
'23514', null, '... a note without a body is refused'
|
|
125
|
+
);
|
|
126
|
+
select throws_ok(
|
|
127
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": ["1.0"], "body": ["x", "y"]}]') $$,
|
|
128
|
+
'23514', null, '... arrays where strings belong are refused'
|
|
129
|
+
);
|
|
130
|
+
select throws_ok(
|
|
131
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[[{"version": "1.0", "body": "x"}]]') $$,
|
|
132
|
+
'23514', null, '... a nested array is refused'
|
|
133
|
+
);
|
|
134
|
+
select throws_ok(
|
|
135
|
+
$$ insert into public.app_release_policy (app_id, notes) values ('genesis-test-c', '[{"version": "1.0", "body": "x", "title": 3}]') $$,
|
|
136
|
+
'23514', null, '... a title must be a string'
|
|
137
|
+
);
|
|
138
|
+
select throws_ok(
|
|
139
|
+
$$ insert into public.app_release_policy (app_id, min_version_android) values ('genesis-test-c', '2.0') $$,
|
|
140
|
+
'23514', null, 'a minimum version without its store link is refused (no way out of the screen)'
|
|
141
|
+
);
|
|
142
|
+
select lives_ok(
|
|
143
|
+
$$ insert into public.app_release_policy (app_id) values ('genesis-test-d') $$,
|
|
144
|
+
'a row with nothing set is valid: no minimum, no maintenance, no notes'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
select * from finish();
|
|
148
|
+
rollback;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
-- pgTAP test for 0003_genesis_apple_token_revocation.sql.
|
|
2
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
3
|
+
-- It reads only its own users and rows and rolls everything back. (The claim
|
|
4
|
+
-- cases push any other queued row out of reach first, inside this transaction.)
|
|
5
|
+
begin;
|
|
6
|
+
create extension if not exists pgtap with schema extensions;
|
|
7
|
+
select plan(45);
|
|
8
|
+
|
|
9
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
10
|
+
select ('00000000-0000-4000-8000-0000000003' || n)::uuid, 'authenticated', 'authenticated', 'genesis-apple-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
11
|
+
from unnest(array['a1', 'b2', 'c3']) n;
|
|
12
|
+
|
|
13
|
+
create function pg_temp.u(n text) returns uuid language sql as $$ select ('00000000-0000-4000-8000-0000000003' || n)::uuid $$;
|
|
14
|
+
create function pg_temp.queued(n text) returns bigint language sql as $$
|
|
15
|
+
select count(*) from private.genesis_apple_revocations where user_id = pg_temp.u(n)
|
|
16
|
+
$$;
|
|
17
|
+
|
|
18
|
+
-- The tables: private, row level security on, nothing granted -----------------
|
|
19
|
+
select ok((select relrowsecurity from pg_class where oid = 'private.genesis_apple_tokens'::regclass), 'genesis_apple_tokens has row level security on');
|
|
20
|
+
select ok((select relrowsecurity from pg_class where oid = 'private.genesis_apple_revocations'::regclass), 'genesis_apple_revocations has row level security on');
|
|
21
|
+
select ok(
|
|
22
|
+
not exists (
|
|
23
|
+
select 1
|
|
24
|
+
from unnest(array['anon', 'authenticated']) r(role),
|
|
25
|
+
unnest(array['private.genesis_apple_tokens', 'private.genesis_apple_revocations']) t(tbl),
|
|
26
|
+
unnest(array['select', 'insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p(priv)
|
|
27
|
+
where has_table_privilege(r.role, t.tbl, p.priv)
|
|
28
|
+
),
|
|
29
|
+
'anon and authenticated have no privilege on either table'
|
|
30
|
+
);
|
|
31
|
+
select ok(
|
|
32
|
+
not exists (
|
|
33
|
+
select 1 from pg_class c, aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
34
|
+
where c.oid in ('private.genesis_apple_tokens'::regclass, 'private.genesis_apple_revocations'::regclass) and a.grantee = 0
|
|
35
|
+
),
|
|
36
|
+
'public has no grant on either table'
|
|
37
|
+
);
|
|
38
|
+
select ok(not has_schema_privilege('anon', 'private', 'usage'), 'anon cannot use the private schema');
|
|
39
|
+
select ok(not has_schema_privilege('authenticated', 'private', 'usage'), 'authenticated cannot use the private schema');
|
|
40
|
+
|
|
41
|
+
-- The functions: security definer, empty search_path, service_role only ---------
|
|
42
|
+
create temp table genesis_fns (sig regprocedure, public_api boolean);
|
|
43
|
+
insert into genesis_fns values
|
|
44
|
+
('private.genesis_apple_queue_revocation()'::regprocedure, false),
|
|
45
|
+
('public.genesis_apple_token_save(uuid, text, text)'::regprocedure, true),
|
|
46
|
+
('public.genesis_apple_revocations_claim(integer)'::regprocedure, true),
|
|
47
|
+
('public.genesis_apple_revocation_done(bigint)'::regprocedure, true),
|
|
48
|
+
('public.genesis_apple_revocation_failed(bigint, text)'::regprocedure, true);
|
|
49
|
+
|
|
50
|
+
select ok(
|
|
51
|
+
(select bool_and(p.prosecdef and p.proconfig @> array['search_path=""']) from genesis_fns f join pg_proc p on p.oid = f.sig),
|
|
52
|
+
'every function is security definer with an empty search_path'
|
|
53
|
+
);
|
|
54
|
+
select ok(
|
|
55
|
+
(select bool_and(p.proowner::regrole::text = 'postgres') from genesis_fns f join pg_proc p on p.oid = f.sig),
|
|
56
|
+
'every function is owned by postgres, as hosted migrations run'
|
|
57
|
+
);
|
|
58
|
+
select ok(
|
|
59
|
+
not exists (select 1 from genesis_fns f where has_function_privilege('anon', f.sig, 'execute')),
|
|
60
|
+
'anon can execute none of them'
|
|
61
|
+
);
|
|
62
|
+
select ok(
|
|
63
|
+
not exists (select 1 from genesis_fns f where has_function_privilege('authenticated', f.sig, 'execute')),
|
|
64
|
+
'authenticated can execute none of them'
|
|
65
|
+
);
|
|
66
|
+
select ok(
|
|
67
|
+
not exists (
|
|
68
|
+
select 1 from genesis_fns f join pg_proc p on p.oid = f.sig, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
69
|
+
where a.grantee = 0 and a.privilege_type = 'EXECUTE'
|
|
70
|
+
),
|
|
71
|
+
'public has no execute grant on any of them'
|
|
72
|
+
);
|
|
73
|
+
select ok(
|
|
74
|
+
(select bool_and(has_function_privilege('service_role', f.sig, 'execute')) from genesis_fns f where f.public_api),
|
|
75
|
+
'service_role can execute the four public functions'
|
|
76
|
+
);
|
|
77
|
+
select ok(
|
|
78
|
+
not has_function_privilege('service_role', 'private.genesis_apple_queue_revocation()', 'execute'),
|
|
79
|
+
'service_role cannot call the trigger function directly'
|
|
80
|
+
);
|
|
81
|
+
select is(
|
|
82
|
+
(select count(*)::int from pg_trigger where tgrelid = 'auth.users'::regclass and tgname = 'genesis_apple_queue_revocation' and not tgisinternal and tgenabled <> 'D'),
|
|
83
|
+
1,
|
|
84
|
+
'the delete trigger is on auth.users'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- A signed-in person can't reach a token, theirs or anyone's -----------------------
|
|
88
|
+
insert into private.genesis_apple_tokens (user_id, client_id, refresh_token) values (pg_temp.u('a1'), 'com.example.app', 'rt-a1-old');
|
|
89
|
+
|
|
90
|
+
select set_config('request.jwt.claims', '{"sub":"00000000-0000-4000-8000-0000000003a1","role":"authenticated"}', true);
|
|
91
|
+
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-0000000003a1', true);
|
|
92
|
+
set local role authenticated;
|
|
93
|
+
select throws_ok($$ select refresh_token from private.genesis_apple_tokens $$, '42501', null, 'authenticated cannot read the tokens table');
|
|
94
|
+
select throws_ok($$ select refresh_token from private.genesis_apple_revocations $$, '42501', null, 'authenticated cannot read the queue');
|
|
95
|
+
select throws_ok(
|
|
96
|
+
$$ select public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'com.example.app', 'planted') $$,
|
|
97
|
+
'42501', null, 'authenticated cannot save a token'
|
|
98
|
+
);
|
|
99
|
+
select throws_ok($$ select * from public.genesis_apple_revocations_claim(100) $$, '42501', null, 'authenticated cannot claim the queue');
|
|
100
|
+
select throws_ok($$ select public.genesis_apple_revocation_done(1) $$, '42501', null, 'authenticated cannot drop a queued token');
|
|
101
|
+
reset role;
|
|
102
|
+
|
|
103
|
+
set local role anon;
|
|
104
|
+
select throws_ok($$ select * from public.genesis_apple_revocations_claim(100) $$, '42501', null, 'anon cannot claim the queue');
|
|
105
|
+
reset role;
|
|
106
|
+
|
|
107
|
+
-- Saving (service_role, as the Edge Function) ------------------------------------
|
|
108
|
+
set local role service_role;
|
|
109
|
+
select is(public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003a1', 'com.example.app', 'rt-a1-new'), 'stored', 'a sign-in''s token is stored');
|
|
110
|
+
reset role;
|
|
111
|
+
select is(
|
|
112
|
+
(select array_agg(refresh_token) from private.genesis_apple_tokens where user_id = pg_temp.u('a1')),
|
|
113
|
+
array['rt-a1-new'],
|
|
114
|
+
'... replacing the earlier one for that client id'
|
|
115
|
+
);
|
|
116
|
+
set local role service_role;
|
|
117
|
+
select is(public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003a1', 'com.example.app.dev', 'rt-a1-dev'), 'stored', 'another client id keeps its own token');
|
|
118
|
+
select is(
|
|
119
|
+
public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003ff', 'com.example.app', 'rt-gone'),
|
|
120
|
+
'queued',
|
|
121
|
+
'a token for a login that is already gone goes to the queue'
|
|
122
|
+
);
|
|
123
|
+
select throws_ok(
|
|
124
|
+
$$ select public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'not a bundle id!', 'rt') $$,
|
|
125
|
+
'23514', null, 'a malformed client id is refused'
|
|
126
|
+
);
|
|
127
|
+
reset role;
|
|
128
|
+
create function pg_temp.save_error_text() returns text language plpgsql as $f$
|
|
129
|
+
declare msg text; det text;
|
|
130
|
+
begin
|
|
131
|
+
perform public.genesis_apple_token_save('00000000-0000-4000-8000-0000000003b2', 'com.example.app', repeat('secret-token-', 400));
|
|
132
|
+
return 'no error';
|
|
133
|
+
exception when others then
|
|
134
|
+
get stacked diagnostics msg = message_text, det = pg_exception_detail;
|
|
135
|
+
return coalesce(msg, '') || '|' || coalesce(det, '');
|
|
136
|
+
end $f$;
|
|
137
|
+
select ok(pg_temp.save_error_text() !~ 'secret-token', '... and a refused token never appears in the error');
|
|
138
|
+
select is(pg_temp.queued('ff'), 1::bigint, '... and is queued once');
|
|
139
|
+
|
|
140
|
+
-- Deleting the login queues its tokens -------------------------------------------
|
|
141
|
+
delete from auth.users where id = pg_temp.u('a1');
|
|
142
|
+
select is((select count(*) from private.genesis_apple_tokens where user_id = pg_temp.u('a1')), 0::bigint, 'deleting the login removes its stored tokens');
|
|
143
|
+
select is(
|
|
144
|
+
(select array_agg(refresh_token order by client_id) from private.genesis_apple_revocations where user_id = pg_temp.u('a1')),
|
|
145
|
+
array['rt-a1-new', 'rt-a1-dev'],
|
|
146
|
+
'... after queueing every one of them for revocation'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
-- A refused delete queues nothing ---------------------------------------------------
|
|
150
|
+
insert into private.genesis_apple_tokens (user_id, client_id, refresh_token) values (pg_temp.u('b2'), 'com.example.app', 'rt-b2');
|
|
151
|
+
create table public.genesis_test_apple_kept (person uuid references auth.users (id));
|
|
152
|
+
insert into public.genesis_test_apple_kept values (pg_temp.u('b2'));
|
|
153
|
+
select throws_ok($$ delete from auth.users where id = '00000000-0000-4000-8000-0000000003b2' $$, '23503', null, 'a delete refused by a foreign key fails');
|
|
154
|
+
select is(pg_temp.queued('b2'), 0::bigint, '... and queues nothing');
|
|
155
|
+
select is((select count(*) from private.genesis_apple_tokens where user_id = pg_temp.u('b2')), 1::bigint, '... and keeps the stored token');
|
|
156
|
+
|
|
157
|
+
-- Claiming, retrying and finishing ------------------------------------------------
|
|
158
|
+
update private.genesis_apple_revocations set next_attempt_at = 'infinity' where user_id not in (pg_temp.u('a1'), pg_temp.u('ff'));
|
|
159
|
+
|
|
160
|
+
create temp table claimed (id bigint, client_id text, refresh_token text, attempts integer);
|
|
161
|
+
grant insert on claimed to service_role;
|
|
162
|
+
set local role service_role;
|
|
163
|
+
insert into claimed select * from public.genesis_apple_revocations_claim(100);
|
|
164
|
+
reset role;
|
|
165
|
+
select is(
|
|
166
|
+
(select array_agg(c.refresh_token order by c.refresh_token) from claimed c),
|
|
167
|
+
array['rt-a1-dev', 'rt-a1-new', 'rt-gone'],
|
|
168
|
+
'a claim hands out every due revocation'
|
|
169
|
+
);
|
|
170
|
+
select ok((select bool_and(c.attempts = 1) from claimed c), '... counting the attempt');
|
|
171
|
+
select ok(
|
|
172
|
+
(select bool_and(r.next_attempt_at > now()) from private.genesis_apple_revocations r join claimed c using (id)),
|
|
173
|
+
'... and leasing each row until its next retry'
|
|
174
|
+
);
|
|
175
|
+
create temp table ids as select (select id from claimed where refresh_token = 'rt-a1-dev') as failing, (select id from claimed where refresh_token = 'rt-a1-new') as revoked;
|
|
176
|
+
set local role service_role;
|
|
177
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 0::bigint, 'a second claim takes none of the leased rows');
|
|
178
|
+
reset role;
|
|
179
|
+
select set_config('genesis.failing', (select failing::text from ids), true);
|
|
180
|
+
select set_config('genesis.revoked', (select revoked::text from ids), true);
|
|
181
|
+
set local role service_role;
|
|
182
|
+
select is(public.genesis_apple_revocation_failed(current_setting('genesis.failing')::bigint, repeat('x', 300)), 'retry', 'a failure is kept for a retry');
|
|
183
|
+
select public.genesis_apple_revocation_done(current_setting('genesis.revoked')::bigint);
|
|
184
|
+
reset role;
|
|
185
|
+
select is(
|
|
186
|
+
(select length(last_error) from private.genesis_apple_revocations where refresh_token = 'rt-a1-dev'),
|
|
187
|
+
200,
|
|
188
|
+
'... with its reason cut to 200 characters'
|
|
189
|
+
);
|
|
190
|
+
select is(
|
|
191
|
+
(select count(*) from private.genesis_apple_revocations where refresh_token = 'rt-a1-new'),
|
|
192
|
+
0::bigint,
|
|
193
|
+
'a revoked token is forgotten'
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
-- Backoff: one minute, doubling, at most a day.
|
|
197
|
+
update private.genesis_apple_revocations set next_attempt_at = now() - interval '1 second', attempts = 30 where refresh_token = 'rt-a1-dev';
|
|
198
|
+
set local role service_role;
|
|
199
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 1::bigint, 'a due retry is claimed again');
|
|
200
|
+
reset role;
|
|
201
|
+
select ok(
|
|
202
|
+
(select next_attempt_at between now() + interval '23 hours 59 minutes' and now() + interval '1 day' from private.genesis_apple_revocations where refresh_token = 'rt-a1-dev'),
|
|
203
|
+
'retries are at most a day apart'
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
-- Giving up: after the 30th attempt the token goes and the row stays as the record.
|
|
207
|
+
set local role service_role;
|
|
208
|
+
select is(public.genesis_apple_revocation_failed(current_setting('genesis.failing')::bigint, 'check: invalid_client (HTTP 400)'), 'gave_up', 'the 30th failed attempt gives up');
|
|
209
|
+
select public.genesis_apple_revocation_done(current_setting('genesis.failing')::bigint);
|
|
210
|
+
reset role;
|
|
211
|
+
select ok(
|
|
212
|
+
(select refresh_token is null and last_error like 'gave_up after 31 attempts: check: invalid_client%' from private.genesis_apple_revocations where id = current_setting('genesis.failing')::bigint),
|
|
213
|
+
'... dropping the token and keeping the row, which done() leaves alone'
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
-- An Apple login with no stored token leaves a record -------------------------------
|
|
217
|
+
insert into auth.identities (provider_id, user_id, identity_data, provider)
|
|
218
|
+
values ('apple-sub-c3', pg_temp.u('c3'), '{"sub": "apple-sub-c3"}', 'apple');
|
|
219
|
+
delete from auth.users where id = pg_temp.u('c3');
|
|
220
|
+
select ok(
|
|
221
|
+
(select count(*) = 1 and bool_and(refresh_token is null and client_id is null and last_error like 'no_token:%') from private.genesis_apple_revocations where user_id = pg_temp.u('c3')),
|
|
222
|
+
'deleting an Apple login with no stored token records that nothing was revoked'
|
|
223
|
+
);
|
|
224
|
+
update private.genesis_apple_revocations set next_attempt_at = now() - interval '1 second' where user_id = pg_temp.u('c3');
|
|
225
|
+
set local role service_role;
|
|
226
|
+
select is((select count(*) from public.genesis_apple_revocations_claim(100)), 0::bigint, '... and a claim never hands out a row without a token');
|
|
227
|
+
reset role;
|
|
228
|
+
|
|
229
|
+
select * from finish();
|
|
230
|
+
rollback;
|