@fayz-ai/db 0.10.0 → 0.12.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/dist/index.cjs +39 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -2
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +553 -0
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/025_created_by.sql +75 -0
- package/migrations/026_audit_trail.sql +73 -0
- package/migrations/027_domain_events.sql +266 -0
- package/migrations/028_tenant_scoped_token.sql +127 -0
- package/migrations/029_connections.sql +186 -0
- package/migrations/030_effect_idempotency.sql +159 -0
- package/migrations/031_sync_run_message.sql +39 -0
- package/migrations/032_connection_secrets.sql +227 -0
- package/migrations/033_sync_schedule.sql +651 -0
- package/migrations/034_custom_fields.sql +55 -0
- package/migrations/035_field_registry.sql +148 -0
- package/migrations/036_analytics_run_batch.sql +84 -0
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +81 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/package.json +7 -4
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 042_unit_scope_policies.sql — the only file here that changes an answer.
|
|
3
|
+
--
|
|
4
|
+
-- WHY A RESTRICTIVE POLICY AND NOT A REWRITE. The obvious move is to extend the
|
|
5
|
+
-- canonical predicate — `tenant_id IN (SELECT public.user_tenant_ids()) AND …`
|
|
6
|
+
-- — on every table. That would mean re-emitting ~321 policy definitions across
|
|
7
|
+
-- 148 tables in db, shop, courses and eight plugin trees, and the migrations
|
|
8
|
+
-- that created them CANNOT be edited: a checksum change on an applied file is a
|
|
9
|
+
-- MigrationDriftError, a hard stop in cli/src/lib/ledger.ts.
|
|
10
|
+
--
|
|
11
|
+
-- Postgres already has the operator for this. Restrictive policies are ANDed
|
|
12
|
+
-- with the OR of every permissive policy on the table. So one policy per table,
|
|
13
|
+
-- added here, composes with everything that already exists — no plugin SQL
|
|
14
|
+
-- changes, no CI-gate contortion, and `DROP POLICY unit_scope ON …` reverts the
|
|
15
|
+
-- entire feature.
|
|
16
|
+
--
|
|
17
|
+
-- It also closes a hole a rewrite would have left open. `storefront_public_read`
|
|
18
|
+
-- on plg_shop_products is `TO anon, authenticated`; because RLS ORs permissive
|
|
19
|
+
-- policies, a signed-in staff member reads the whole catalogue through that
|
|
20
|
+
-- door — which is exactly the "Botox in 2 of 15 units" case, defeated. A
|
|
21
|
+
-- restrictive policy ANDs on top and shuts it.
|
|
22
|
+
--
|
|
23
|
+
-- WHY THE PREDICATE IS LITERAL TEXT AND NOT A FUNCTION. A helper taking the
|
|
24
|
+
-- row's own columns — record_visible(id, unit_id, owner_id) — is CORRELATED.
|
|
25
|
+
-- RLS predicates are injected into RangeTblEntry.securityQuals, which
|
|
26
|
+
-- pull_up_sublinks never sees, so a sublink there is never turned into a
|
|
27
|
+
-- semijoin; a correlated one becomes a SubPlan re-executed once PER ROW. Worse,
|
|
28
|
+
-- such a helper must be SECURITY DEFINER with SET search_path, and either of
|
|
29
|
+
-- those alone makes it non-inlinable. Every disjunct below is UNCORRELATED: one
|
|
30
|
+
-- InitPlan per query, hashed, then a cheap probe per row.
|
|
31
|
+
--
|
|
32
|
+
-- WHAT IT COSTS A TENANT THAT DOES NOT USE UNITS. The first disjunct is a
|
|
33
|
+
-- zero-argument STABLE function wrapped in `(SELECT …)`, so it is one InitPlan
|
|
34
|
+
-- per query. On a pool where nobody has flipped the switch it returns false,
|
|
35
|
+
-- `NOT false` is true, and OR does not evaluate its remaining arms — one
|
|
36
|
+
-- boolean test per row, no hash built, plan shape unchanged.
|
|
37
|
+
--
|
|
38
|
+
-- Idempotent: every policy is dropped by name before being created.
|
|
39
|
+
-- ============================================================================
|
|
40
|
+
|
|
41
|
+
DO $$
|
|
42
|
+
DECLARE
|
|
43
|
+
r record;
|
|
44
|
+
v_read text;
|
|
45
|
+
v_write text;
|
|
46
|
+
BEGIN
|
|
47
|
+
FOR r IN SELECT resource_table, unit_column FROM public.scoped_resources ORDER BY resource_table LOOP
|
|
48
|
+
CONTINUE WHEN to_regclass('public.' || r.resource_table) IS NULL;
|
|
49
|
+
|
|
50
|
+
-- %1$s is the table name as a STRING LITERAL for the helper arguments, and
|
|
51
|
+
-- %2$I / %3$I are the identifiers. Baking the name in as a constant is what
|
|
52
|
+
-- keeps granted_record_ids() uncorrelated.
|
|
53
|
+
v_read := format($p$
|
|
54
|
+
NOT (SELECT public.unit_scoping_active())
|
|
55
|
+
OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
|
|
56
|
+
OR tenant_id NOT IN (SELECT public.user_tenant_ids())
|
|
57
|
+
OR tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
58
|
+
OR %3$I = (SELECT auth.uid())
|
|
59
|
+
OR id IN (SELECT public.granted_record_ids(%1$L))
|
|
60
|
+
OR (
|
|
61
|
+
tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'tenant'))
|
|
62
|
+
AND id NOT IN (SELECT public.restricted_record_ids(%1$L))
|
|
63
|
+
)
|
|
64
|
+
OR (
|
|
65
|
+
(
|
|
66
|
+
tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'unit'))
|
|
67
|
+
OR (%3$I IS NULL AND tenant_id IN (SELECT public.resource_mode_tenants(%1$L, 'owner')))
|
|
68
|
+
)
|
|
69
|
+
AND (
|
|
70
|
+
%2$I IN (SELECT public.user_unit_ids())
|
|
71
|
+
OR (%2$I IS NULL AND id NOT IN (SELECT public.restricted_record_ids(%1$L)))
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
$p$, r.resource_table, r.unit_column, 'owner_id');
|
|
75
|
+
|
|
76
|
+
v_write := format($p$
|
|
77
|
+
NOT (SELECT public.unit_scoping_active())
|
|
78
|
+
OR tenant_id NOT IN (SELECT public.unit_scoped_tenants())
|
|
79
|
+
OR tenant_id NOT IN (SELECT public.user_tenant_ids())
|
|
80
|
+
OR tenant_id IN (SELECT public.user_admin_tenant_ids())
|
|
81
|
+
OR (
|
|
82
|
+
(%2$I IS NULL OR %2$I IN (SELECT public.user_unit_ids()))
|
|
83
|
+
AND (%3$I IS NULL OR %3$I = (SELECT auth.uid()))
|
|
84
|
+
)
|
|
85
|
+
$p$, r.resource_table, r.unit_column, 'owner_id');
|
|
86
|
+
|
|
87
|
+
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.resource_table);
|
|
88
|
+
EXECUTE format('DROP POLICY IF EXISTS unit_scope ON public.%I', r.resource_table);
|
|
89
|
+
-- `AS RESTRICTIVE` sits on its own concatenated line because it is the
|
|
90
|
+
-- single word this whole file depends on: without it the policy becomes
|
|
91
|
+
-- permissive, ORs with everything else on the table and grants MORE access
|
|
92
|
+
-- than there was before. negative-control.sh deletes exactly this line and
|
|
93
|
+
-- demands the bench go red — see the case list there.
|
|
94
|
+
EXECUTE format(
|
|
95
|
+
'CREATE POLICY unit_scope ON public.%I '
|
|
96
|
+
|| ' AS RESTRICTIVE '
|
|
97
|
+
|| ' FOR ALL TO authenticated USING (%s) WITH CHECK (%s)',
|
|
98
|
+
r.resource_table, v_read, v_write);
|
|
99
|
+
END LOOP;
|
|
100
|
+
END $$;
|
|
101
|
+
|
|
102
|
+
-- ── What the predicate says, in words ───────────────────────────────────────
|
|
103
|
+
--
|
|
104
|
+
-- READ. A row is visible when ANY of these holds, checked left to right:
|
|
105
|
+
--
|
|
106
|
+
-- 1. no tenant in this pool uses units → the whole feature is off
|
|
107
|
+
-- 2. this row's tenant does not use units → that tenant is unchanged
|
|
108
|
+
-- 3. the row is not mine to begin with → a shopper browsing a
|
|
109
|
+
-- storefront is governed by
|
|
110
|
+
-- whatever let them in, not
|
|
111
|
+
-- by this
|
|
112
|
+
-- 4. I am owner/admin of the tenant → the franchisor sees the brand
|
|
113
|
+
-- 5. I own the row
|
|
114
|
+
-- 6. the row was explicitly shared with me, my team or a unit I reach
|
|
115
|
+
-- 7. this record type is org-wide for this tenant, AND nobody has named the
|
|
116
|
+
-- units it belongs to → the ordinary case
|
|
117
|
+
-- 8. this record type is unit-scoped (or is owner-scoped and has no owner at
|
|
118
|
+
-- all), AND the row's unit is one I reach, or it has no unit and nobody
|
|
119
|
+
-- named units for it
|
|
120
|
+
--
|
|
121
|
+
-- Rule 7's second half and rule 8's are the same idea, and it is what makes
|
|
122
|
+
-- "Botox in 2 of 15" work with no extra table: a record with NO unit grant
|
|
123
|
+
-- belongs to the whole brand; the moment somebody grants it to two units, the
|
|
124
|
+
-- org-wide pass stops applying and only those two units (rule 6) see it.
|
|
125
|
+
--
|
|
126
|
+
-- An ownerless row can never be private (rule 8's parenthesis). Records that
|
|
127
|
+
-- predate 025 have no author and therefore no owner, and a tenant switching a
|
|
128
|
+
-- record type to "only the owner" must not make its whole history vanish.
|
|
129
|
+
--
|
|
130
|
+
-- WRITE. You may stamp a row with no unit, or with a unit you actually reach,
|
|
131
|
+
-- and you may not hand a record to somebody else. Reassigning a record AWAY
|
|
132
|
+
-- from yourself is allowed — that is a legitimate handoff, and losing your own
|
|
133
|
+
-- access is the correct consequence. Claiming someone else's is blocked by the
|
|
134
|
+
-- USING clause, which the pre-image must satisfy before WITH CHECK ever runs.
|
|
135
|
+
--
|
|
136
|
+
-- Writing a row with NO unit is deliberately permitted: product decision — a
|
|
137
|
+
-- NULL unit is a legitimate, permanent state ("belongs to the whole
|
|
138
|
+
-- organization"), and forbidding it would break every insert path the day a
|
|
139
|
+
-- tenant flips the switch.
|
|
140
|
+
--
|
|
141
|
+
-- WHERE THIS GUARANTEE STOPS. `service_role` has BYPASSRLS, so every edge
|
|
142
|
+
-- function reads across units by construction; any edge function returning rows
|
|
143
|
+
-- to a person must filter for itself. The sharpest edge is public.agent_guard
|
|
144
|
+
-- (015), which has no unit dimension yet — an agent RPC acting for a
|
|
145
|
+
-- unit-restricted user is not restricted. That is tracked, not solved here.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 043_view_invoker.sql — the views that were never anybody's tenant.
|
|
3
|
+
--
|
|
4
|
+
-- THIS IS NOT PART OF UNIT SCOPING. It is a cross-tenant hole that predates it,
|
|
5
|
+
-- found while auditing what 042's predicate would and would not reach, and it
|
|
6
|
+
-- has to close first or "the guarantee is the API's" is false on the day it
|
|
7
|
+
-- ships.
|
|
8
|
+
--
|
|
9
|
+
-- A Postgres view runs with the privileges of its OWNER unless it is created
|
|
10
|
+
-- `WITH (security_invoker = true)`. Migrations here are applied as `postgres`,
|
|
11
|
+
-- who owns the tables, and a table owner is exempt from that table's RLS unless
|
|
12
|
+
-- the table is set FORCE ROW LEVEL SECURITY — which nothing in this tree does.
|
|
13
|
+
-- Meanwhile 001_core and 008_grants hand `authenticated` a blanket SELECT.
|
|
14
|
+
--
|
|
15
|
+
-- Put together, on a shared industry pool — one Supabase project carrying
|
|
16
|
+
-- several clinics — any signed-in user of ANY tenant could
|
|
17
|
+
--
|
|
18
|
+
-- SELECT * FROM public.v_documents;
|
|
19
|
+
--
|
|
20
|
+
-- and read every tenant's patient documents. Same for invoice balances and the
|
|
21
|
+
-- agenda's five report views. Twenty-one views in the tree already carry
|
|
22
|
+
-- `security_invoker`; these did not, and nothing distinguishes them except who
|
|
23
|
+
-- wrote them.
|
|
24
|
+
--
|
|
25
|
+
-- WHY A CORE SWEEP AND NOT A FIX IN EACH PLUGIN. Fixing it where it lives means
|
|
26
|
+
-- a new migration in plugin-forms, plugin-financial, plugin-agenda and courses,
|
|
27
|
+
-- each of which only reaches a pool when that plugin is next applied. The hole
|
|
28
|
+
-- is open now, on live pools, and one file that closes it everywhere is worth
|
|
29
|
+
-- the ownership smudge. The plugins should still carry the fix forward in their
|
|
30
|
+
-- own next migration; this file is idempotent and will simply agree with them.
|
|
31
|
+
--
|
|
32
|
+
-- WHAT IS DELIBERATELY LEFT ALONE — the three anon-facing views:
|
|
33
|
+
--
|
|
34
|
+
-- public.plg_shop_catalog the storefront's product list
|
|
35
|
+
-- public.v_public_services public booking
|
|
36
|
+
-- public.v_public_blog_posts the public blog
|
|
37
|
+
--
|
|
38
|
+
-- Those are MEANT to answer without a session, and flipping them to invoker
|
|
39
|
+
-- makes them resolve as `anon`, which returns rows only if every table they
|
|
40
|
+
-- touch carries an anon policy. Some do (shop 0020's storefront_public_read),
|
|
41
|
+
-- some are unverified. Turning a storefront dark is not an acceptable way to
|
|
42
|
+
-- fix a leak in a report screen, so they are handled separately, against a live
|
|
43
|
+
-- pool, with the storefront actually open in a browser.
|
|
44
|
+
--
|
|
45
|
+
-- ALTER VIEW ... SET is additive and idempotent. Every statement is guarded, so
|
|
46
|
+
-- a pool without a given plugin simply skips it.
|
|
47
|
+
-- ============================================================================
|
|
48
|
+
|
|
49
|
+
DO $$
|
|
50
|
+
DECLARE
|
|
51
|
+
v text;
|
|
52
|
+
-- Named one by one rather than swept, so this file can never silently reach a
|
|
53
|
+
-- view somebody adds later for anon.
|
|
54
|
+
leaky text[] := ARRAY[
|
|
55
|
+
-- plugin-forms: the patient record, and the one that joins it to people
|
|
56
|
+
'v_documents',
|
|
57
|
+
'v_frm_documents',
|
|
58
|
+
-- plugin-financial: what is still owed, per invoice
|
|
59
|
+
'v_invoice_balances',
|
|
60
|
+
-- plugin-agenda: the five report read-models
|
|
61
|
+
'rep_appointments_by_period',
|
|
62
|
+
'rep_confirmation_queue',
|
|
63
|
+
'rep_new_clients',
|
|
64
|
+
'rep_revenue_by_professional',
|
|
65
|
+
'rep_revenue_by_service',
|
|
66
|
+
-- courses
|
|
67
|
+
'plg_courses_rep_revenue'
|
|
68
|
+
];
|
|
69
|
+
BEGIN
|
|
70
|
+
FOREACH v IN ARRAY leaky LOOP
|
|
71
|
+
-- to_regclass and not information_schema: a pool has whichever plugins it
|
|
72
|
+
-- has, and the ledger is not a reliable account of that.
|
|
73
|
+
CONTINUE WHEN to_regclass('public.' || v) IS NULL;
|
|
74
|
+
EXECUTE format('ALTER VIEW public.%I SET (security_invoker = true)', v);
|
|
75
|
+
END LOOP;
|
|
76
|
+
END $$;
|
|
77
|
+
|
|
78
|
+
-- What changes on screen: a report that was quietly summing other tenants' rows
|
|
79
|
+
-- now sums only its own. That is a smaller number and it is the correct one —
|
|
80
|
+
-- but it IS a visible change, which is why this lives in its own file and can
|
|
81
|
+
-- be rolled back on its own.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 044_unit_member_facts.sql — the two facts a unit roster is asked for.
|
|
3
|
+
--
|
|
4
|
+
-- "Quem trabalha aqui" is never only a list of names. The two questions that
|
|
5
|
+
-- follow it are always the same: since when, and are they actually using it.
|
|
6
|
+
--
|
|
7
|
+
-- since when → location_members.created_at, which 001 never gave it.
|
|
8
|
+
-- last access → auth.users.last_sign_in_at, which no client role can read,
|
|
9
|
+
-- and should not be able to: that table also holds password
|
|
10
|
+
-- hashes and recovery tokens.
|
|
11
|
+
--
|
|
12
|
+
-- So the second one comes through a function that returns ONLY the two harmless
|
|
13
|
+
-- columns, only for members of a tenant the caller administers. An admin can
|
|
14
|
+
-- already see every member's name and e-mail on the team screen; when they last
|
|
15
|
+
-- signed in is strictly less than that, and it is the difference between "we
|
|
16
|
+
-- gave Ana access" and "Ana has been using it".
|
|
17
|
+
--
|
|
18
|
+
-- Additive and idempotent.
|
|
19
|
+
-- ============================================================================
|
|
20
|
+
|
|
21
|
+
ALTER TABLE public.location_members
|
|
22
|
+
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
|
23
|
+
|
|
24
|
+
COMMENT ON COLUMN public.location_members.created_at IS
|
|
25
|
+
'When this person was given access to this unit.';
|
|
26
|
+
|
|
27
|
+
-- Rows that predate the column all default to now(), which would claim everyone
|
|
28
|
+
-- was bound today. Left as-is deliberately: a wrong date is worse than a vague
|
|
29
|
+
-- one, and the screen shows nothing older than the column itself is.
|
|
30
|
+
|
|
31
|
+
CREATE OR REPLACE FUNCTION public.tenant_member_activity(p_tenant_id uuid)
|
|
32
|
+
RETURNS TABLE (user_id uuid, last_sign_in_at timestamptz, signed_up_at timestamptz)
|
|
33
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
34
|
+
SET search_path = public, pg_temp
|
|
35
|
+
AS $$
|
|
36
|
+
SELECT u.id, u.last_sign_in_at, u.created_at
|
|
37
|
+
FROM auth.users u
|
|
38
|
+
JOIN public.tenant_members tm ON tm.user_id = u.id
|
|
39
|
+
WHERE tm.tenant_id = p_tenant_id
|
|
40
|
+
-- The gate, and the only one: an admin of THIS tenant, and nobody else.
|
|
41
|
+
-- Without it the function is a directory of every last_sign_in_at in the
|
|
42
|
+
-- pool, reachable by anyone with an anon key.
|
|
43
|
+
AND p_tenant_id IN (SELECT public.user_admin_tenant_ids());
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
REVOKE ALL ON FUNCTION public.tenant_member_activity(uuid) FROM public, anon;
|
|
47
|
+
GRANT EXECUTE ON FUNCTION public.tenant_member_activity(uuid) TO authenticated, service_role;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 045_unit_entry.sql — entrar no sistema já na unidade certa.
|
|
3
|
+
--
|
|
4
|
+
-- A fronteira de unidade existe desde a 039. O que faltava era a pergunta que
|
|
5
|
+
-- todo funcionário de rede responde toda manhã: "em qual loja eu estou?".
|
|
6
|
+
--
|
|
7
|
+
-- Responder isso com um seletor vazio é empurrar trabalho para quem só queria
|
|
8
|
+
-- abrir a agenda. Três fatos resolvem quase todos os casos, e nenhum deles
|
|
9
|
+
-- precisa de permissão do navegador:
|
|
10
|
+
--
|
|
11
|
+
-- 1. o que ESTA MÁQUINA abriu por último → a recepcionista do balcão da
|
|
12
|
+
-- Ipanema usa sempre o mesmo computador, e ele fica na Ipanema;
|
|
13
|
+
-- 2. o que ESTA PESSOA abriu por último em qualquer lugar → ela trocou de
|
|
14
|
+
-- máquina, mas não de hábito;
|
|
15
|
+
-- 3. só existe uma unidade ao alcance dela → não há escolha a fazer.
|
|
16
|
+
--
|
|
17
|
+
-- Só o que sobra depois disso justifica pedir a localização do navegador, e por
|
|
18
|
+
-- isso as coordenadas aqui são OPCIONAIS e a tela só oferece o botão quando
|
|
19
|
+
-- elas existem. Ninguém leva um pedido de permissão de GPS por ter feito login.
|
|
20
|
+
--
|
|
21
|
+
-- NADA AQUI CONCEDE ACESSO. O pino é preferência, não autorização: a unidade
|
|
22
|
+
-- ativa só ESTREITA o que o RLS já deixou passar (ver 042). Um pino apontando
|
|
23
|
+
-- para uma unidade que a pessoa não alcança não vaza nada — é ignorado na
|
|
24
|
+
-- leitura e recusado na escrita, e as duas coisas estão abaixo.
|
|
25
|
+
--
|
|
26
|
+
-- Aditiva e idempotente.
|
|
27
|
+
-- ============================================================================
|
|
28
|
+
|
|
29
|
+
-- ── Coordenadas da unidade (opcionais) ──────────────────────────────────────
|
|
30
|
+
-- Sem serviço de geocodificação: quem cadastra a unidade informa, ou não
|
|
31
|
+
-- informa. Nulo é o estado normal, e "achar a mais perto" simplesmente não é
|
|
32
|
+
-- oferecido para uma rede que nunca preencheu isso.
|
|
33
|
+
ALTER TABLE public.locations
|
|
34
|
+
ADD COLUMN IF NOT EXISTS latitude numeric(9,6),
|
|
35
|
+
ADD COLUMN IF NOT EXISTS longitude numeric(9,6);
|
|
36
|
+
|
|
37
|
+
DO $$
|
|
38
|
+
BEGIN
|
|
39
|
+
IF NOT EXISTS (
|
|
40
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'locations_coords_range'
|
|
41
|
+
) THEN
|
|
42
|
+
ALTER TABLE public.locations
|
|
43
|
+
ADD CONSTRAINT locations_coords_range CHECK (
|
|
44
|
+
(latitude IS NULL OR (latitude BETWEEN -90 AND 90)) AND
|
|
45
|
+
(longitude IS NULL OR (longitude BETWEEN -180 AND 180))
|
|
46
|
+
) NOT VALID;
|
|
47
|
+
END IF;
|
|
48
|
+
END $$;
|
|
49
|
+
|
|
50
|
+
COMMENT ON COLUMN public.locations.latitude IS
|
|
51
|
+
'Opcional. Só serve para oferecer "a unidade mais perto de mim" na entrada.';
|
|
52
|
+
|
|
53
|
+
-- ── O pino, por pessoa, por conta, por máquina ──────────────────────────────
|
|
54
|
+
-- A chave inclui o dispositivo porque a máquina é um proxy melhor de "onde eu
|
|
55
|
+
-- estou fisicamente" do que a conta: o mesmo gerente abre o Leblon no celular
|
|
56
|
+
-- de casa à noite e a Ipanema no balcão de manhã, e nenhuma das duas escolhas
|
|
57
|
+
-- deveria estragar a outra.
|
|
58
|
+
--
|
|
59
|
+
-- Não existe linha "da conta": a preferência da conta é a linha mais recente
|
|
60
|
+
-- entre os dispositivos. Um estado derivado não pode divergir do outro.
|
|
61
|
+
CREATE TABLE IF NOT EXISTS public.user_unit_prefs (
|
|
62
|
+
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
63
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
64
|
+
-- Opaco e sorteado no navegador, nunca uma impressão digital do aparelho:
|
|
65
|
+
-- ele só precisa distinguir "este computador" de "aquele", e um identificador
|
|
66
|
+
-- que não descreve nada não pode ser usado para mais nada.
|
|
67
|
+
device_id text NOT NULL,
|
|
68
|
+
-- Nulo = a pessoa escolheu "todas as unidades" explicitamente, que é uma
|
|
69
|
+
-- escolha e não uma ausência de escolha.
|
|
70
|
+
unit_id uuid REFERENCES public.locations(id) ON DELETE CASCADE,
|
|
71
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
72
|
+
PRIMARY KEY (user_id, tenant_id, device_id)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS user_unit_prefs_recent_idx
|
|
76
|
+
ON public.user_unit_prefs (user_id, tenant_id, updated_at DESC);
|
|
77
|
+
|
|
78
|
+
COMMENT ON TABLE public.user_unit_prefs IS
|
|
79
|
+
'Onde cada pessoa estava por último, por máquina. Preferência de entrada — nunca autorização.';
|
|
80
|
+
|
|
81
|
+
ALTER TABLE public.user_unit_prefs ENABLE ROW LEVEL SECURITY;
|
|
82
|
+
|
|
83
|
+
-- Permissivas: eu leio e escrevo o meu pino, e só o meu.
|
|
84
|
+
DROP POLICY IF EXISTS "user_unit_prefs_own" ON public.user_unit_prefs;
|
|
85
|
+
CREATE POLICY "user_unit_prefs_own" ON public.user_unit_prefs
|
|
86
|
+
FOR ALL TO authenticated
|
|
87
|
+
USING (user_id = (SELECT auth.uid()))
|
|
88
|
+
WITH CHECK (user_id = (SELECT auth.uid()));
|
|
89
|
+
|
|
90
|
+
-- Restritivas, pelo mesmo motivo da 040: esta tabela tem `tenant_id` e não está
|
|
91
|
+
-- na lista de tabelas do núcleo, então a varredura da 002 a redescobre a cada
|
|
92
|
+
-- reaplicação e acrescenta uma policy PERMISSIVA por conta inteira. Permissivas
|
|
93
|
+
-- se somam com OU — sem o par restritivo abaixo, um colega da mesma conta
|
|
94
|
+
-- passaria a ler (e a escrever) em que unidade eu estava.
|
|
95
|
+
DROP POLICY IF EXISTS "user_unit_prefs_select_guard" ON public.user_unit_prefs;
|
|
96
|
+
CREATE POLICY "user_unit_prefs_select_guard" ON public.user_unit_prefs
|
|
97
|
+
AS RESTRICTIVE FOR SELECT TO authenticated
|
|
98
|
+
USING (user_id = (SELECT auth.uid()));
|
|
99
|
+
|
|
100
|
+
DROP POLICY IF EXISTS "user_unit_prefs_insert_guard" ON public.user_unit_prefs;
|
|
101
|
+
CREATE POLICY "user_unit_prefs_insert_guard" ON public.user_unit_prefs
|
|
102
|
+
AS RESTRICTIVE FOR INSERT TO authenticated
|
|
103
|
+
WITH CHECK (user_id = (SELECT auth.uid()));
|
|
104
|
+
|
|
105
|
+
DROP POLICY IF EXISTS "user_unit_prefs_update_guard" ON public.user_unit_prefs;
|
|
106
|
+
CREATE POLICY "user_unit_prefs_update_guard" ON public.user_unit_prefs
|
|
107
|
+
AS RESTRICTIVE FOR UPDATE TO authenticated
|
|
108
|
+
USING (user_id = (SELECT auth.uid()))
|
|
109
|
+
WITH CHECK (user_id = (SELECT auth.uid()));
|
|
110
|
+
|
|
111
|
+
DROP POLICY IF EXISTS "user_unit_prefs_delete_guard" ON public.user_unit_prefs;
|
|
112
|
+
CREATE POLICY "user_unit_prefs_delete_guard" ON public.user_unit_prefs
|
|
113
|
+
AS RESTRICTIVE FOR DELETE TO authenticated
|
|
114
|
+
USING (user_id = (SELECT auth.uid()));
|
|
115
|
+
|
|
116
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.user_unit_prefs TO authenticated;
|
|
117
|
+
GRANT ALL ON public.user_unit_prefs TO service_role;
|
|
118
|
+
|
|
119
|
+
-- Um navegador novo a cada limpeza de cookies gera um device_id novo, e sem
|
|
120
|
+
-- teto a tabela acumularia uma linha por sessão para sempre. Cinco é mais do
|
|
121
|
+
-- que qualquer pessoa usa e menos do que qualquer coisa que incomode.
|
|
122
|
+
CREATE OR REPLACE FUNCTION public.trim_user_unit_prefs()
|
|
123
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER
|
|
124
|
+
SET search_path = public, pg_temp AS $$
|
|
125
|
+
BEGIN
|
|
126
|
+
DELETE FROM public.user_unit_prefs p
|
|
127
|
+
WHERE p.user_id = NEW.user_id
|
|
128
|
+
AND p.tenant_id = NEW.tenant_id
|
|
129
|
+
AND p.device_id NOT IN (
|
|
130
|
+
SELECT device_id FROM public.user_unit_prefs
|
|
131
|
+
WHERE user_id = NEW.user_id AND tenant_id = NEW.tenant_id
|
|
132
|
+
ORDER BY updated_at DESC
|
|
133
|
+
LIMIT 5
|
|
134
|
+
);
|
|
135
|
+
RETURN NULL;
|
|
136
|
+
END $$;
|
|
137
|
+
|
|
138
|
+
DROP TRIGGER IF EXISTS user_unit_prefs_trim ON public.user_unit_prefs;
|
|
139
|
+
CREATE TRIGGER user_unit_prefs_trim
|
|
140
|
+
AFTER INSERT OR UPDATE ON public.user_unit_prefs
|
|
141
|
+
FOR EACH ROW EXECUTE FUNCTION public.trim_user_unit_prefs();
|
|
142
|
+
|
|
143
|
+
-- ── Gravar o pino ───────────────────────────────────────────────────────────
|
|
144
|
+
-- Por função e não por upsert direto para poder recusar um pino impossível. Um
|
|
145
|
+
-- pino inválido não vazaria nada (ele só estreita), mas uma tabela de
|
|
146
|
+
-- preferências que guarda unidades inalcançáveis mente para quem a lê depois.
|
|
147
|
+
CREATE OR REPLACE FUNCTION public.set_entry_unit(
|
|
148
|
+
p_tenant_id uuid,
|
|
149
|
+
p_unit_id uuid,
|
|
150
|
+
p_device_id text
|
|
151
|
+
)
|
|
152
|
+
RETURNS void LANGUAGE plpgsql SECURITY INVOKER
|
|
153
|
+
SET search_path = public, pg_temp AS $$
|
|
154
|
+
BEGIN
|
|
155
|
+
IF p_device_id IS NULL OR length(trim(p_device_id)) = 0 THEN
|
|
156
|
+
RAISE EXCEPTION 'device_id obrigatório';
|
|
157
|
+
END IF;
|
|
158
|
+
|
|
159
|
+
-- "Todas as unidades" (nulo) é sempre válido. Uma unidade só é aceita se a
|
|
160
|
+
-- pessoa a alcança de verdade — a mesma função que o RLS usa, não uma cópia.
|
|
161
|
+
IF p_unit_id IS NOT NULL AND p_unit_id NOT IN (SELECT public.user_unit_ids()) THEN
|
|
162
|
+
RAISE EXCEPTION 'unidade fora do alcance';
|
|
163
|
+
END IF;
|
|
164
|
+
|
|
165
|
+
INSERT INTO public.user_unit_prefs (user_id, tenant_id, device_id, unit_id, updated_at)
|
|
166
|
+
VALUES ((SELECT auth.uid()), p_tenant_id, p_device_id, p_unit_id, now())
|
|
167
|
+
ON CONFLICT (user_id, tenant_id, device_id)
|
|
168
|
+
DO UPDATE SET unit_id = EXCLUDED.unit_id, updated_at = now();
|
|
169
|
+
END $$;
|
|
170
|
+
|
|
171
|
+
GRANT EXECUTE ON FUNCTION public.set_entry_unit(uuid, uuid, text) TO authenticated;
|
|
172
|
+
|
|
173
|
+
-- ── Tudo que a entrada precisa, numa viagem ─────────────────────────────────
|
|
174
|
+
-- A tela pagava cinco idas ao servidor antes de saber em que unidade estava:
|
|
175
|
+
-- chave da conta, árvore, vínculos, registro de tabelas escopadas e pinos. Em
|
|
176
|
+
-- rede móvel isso é o intervalo inteiro entre o login e a primeira lista — e
|
|
177
|
+
-- durante ele a lista já pintou, sem unidade, mostrando linhas que o seletor
|
|
178
|
+
-- diz não estar mostrando.
|
|
179
|
+
--
|
|
180
|
+
-- SECURITY INVOKER de propósito: tudo aqui dentro passa pelo RLS de quem
|
|
181
|
+
-- chamou. Esta função junta viagens, não privilégios.
|
|
182
|
+
CREATE OR REPLACE FUNCTION public.unit_bootstrap(
|
|
183
|
+
p_tenant_id uuid,
|
|
184
|
+
p_device_id text DEFAULT NULL
|
|
185
|
+
)
|
|
186
|
+
RETURNS jsonb LANGUAGE sql STABLE SECURITY INVOKER
|
|
187
|
+
SET search_path = public, pg_temp AS $$
|
|
188
|
+
SELECT jsonb_build_object(
|
|
189
|
+
'mode', COALESCE((
|
|
190
|
+
SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
|
|
191
|
+
), false),
|
|
192
|
+
'units', COALESCE((
|
|
193
|
+
SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
|
|
194
|
+
FROM public.locations l WHERE l.tenant_id = p_tenant_id
|
|
195
|
+
), '[]'::jsonb),
|
|
196
|
+
'bindings', COALESCE((
|
|
197
|
+
SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
|
|
198
|
+
FROM public.location_members lm WHERE lm.user_id = (SELECT auth.uid())
|
|
199
|
+
), '[]'::jsonb),
|
|
200
|
+
'scoped', COALESCE((
|
|
201
|
+
SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
|
|
202
|
+
'unit_column', sr.unit_column, 'shareable', sr.shareable))
|
|
203
|
+
FROM public.scoped_resources sr
|
|
204
|
+
), '{}'::jsonb),
|
|
205
|
+
-- O pino desta máquina ganha do pino da conta quando os dois existem: a
|
|
206
|
+
-- máquina fica na loja, a conta viaja com a pessoa.
|
|
207
|
+
'device_pin', (
|
|
208
|
+
SELECT p.unit_id FROM public.user_unit_prefs p
|
|
209
|
+
WHERE p.user_id = (SELECT auth.uid())
|
|
210
|
+
AND p.tenant_id = p_tenant_id
|
|
211
|
+
AND p.device_id = p_device_id
|
|
212
|
+
),
|
|
213
|
+
'device_pin_set', EXISTS (
|
|
214
|
+
SELECT 1 FROM public.user_unit_prefs p
|
|
215
|
+
WHERE p.user_id = (SELECT auth.uid())
|
|
216
|
+
AND p.tenant_id = p_tenant_id
|
|
217
|
+
AND p.device_id = p_device_id
|
|
218
|
+
),
|
|
219
|
+
'account_pin', (
|
|
220
|
+
SELECT p.unit_id FROM public.user_unit_prefs p
|
|
221
|
+
WHERE p.user_id = (SELECT auth.uid())
|
|
222
|
+
AND p.tenant_id = p_tenant_id
|
|
223
|
+
ORDER BY p.updated_at DESC
|
|
224
|
+
LIMIT 1
|
|
225
|
+
),
|
|
226
|
+
'account_pin_set', EXISTS (
|
|
227
|
+
SELECT 1 FROM public.user_unit_prefs p
|
|
228
|
+
WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
|
|
229
|
+
)
|
|
230
|
+
);
|
|
231
|
+
$$;
|
|
232
|
+
|
|
233
|
+
GRANT EXECUTE ON FUNCTION public.unit_bootstrap(uuid, text) TO authenticated;
|
|
234
|
+
|
|
235
|
+
COMMENT ON FUNCTION public.unit_bootstrap(uuid, text) IS
|
|
236
|
+
'Uma viagem para tudo que a entrada precisa. Invoker: agrupa requisições, não privilégios.';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 046_membership_visible_to_members.sql — a tela de equipe consegue ler a
|
|
3
|
+
-- própria equipe.
|
|
4
|
+
--
|
|
5
|
+
-- `members_select` da 001 é `user_id = auth.uid()`: cada pessoa enxerga **só a
|
|
6
|
+
-- própria linha** de `tenant_members`. A consequência não é sutil.
|
|
7
|
+
--
|
|
8
|
+
-- A tela de Equipe monta a lista a partir de `people` e usa `tenant_members`
|
|
9
|
+
-- como camada de acesso por cima. Com a policy de hoje essa camada tem uma
|
|
10
|
+
-- linha. Na prática:
|
|
11
|
+
--
|
|
12
|
+
-- * a coluna "função" só está certa para quem está olhando;
|
|
13
|
+
-- * quem tem login e NÃO tem cadastro de pessoa (o dono que criou o espaço, o
|
|
14
|
+
-- contador, o sócio) simplesmente **não aparece** — a tela de "quem tem
|
|
15
|
+
-- acesso" omite justamente quem tem acesso;
|
|
16
|
+
-- * a mesma cegueira atinge o vínculo `person_id`, então o sistema não
|
|
17
|
+
-- consegue dizer que a Dra. Silvania do cadastro e o login dela são a mesma
|
|
18
|
+
-- pessoa, mesmo tendo a coluna para isso desde a 019.
|
|
19
|
+
--
|
|
20
|
+
-- Verificado num pool vivo antes de mexer: numa conta com três membros, um
|
|
21
|
+
-- administrador logado lia **um**.
|
|
22
|
+
--
|
|
23
|
+
-- Função de colega não é segredo em lugar nenhum — é o que toda ferramenta de
|
|
24
|
+
-- trabalho mostra na lista de gente. E é a diferença entre uma tela de acesso e
|
|
25
|
+
-- uma tela que finge ser uma.
|
|
26
|
+
--
|
|
27
|
+
-- ESCREVER continua sendo de administrador. `members_update` e `members_delete`
|
|
28
|
+
-- não são tocados: passar a enxergar quem está na equipe não é passar a poder
|
|
29
|
+
-- mudar o papel de ninguém.
|
|
30
|
+
--
|
|
31
|
+
-- `tenant_members` está na lista de tabelas do núcleo da 002, então a varredura
|
|
32
|
+
-- não a alcança e não existe policy permissiva chegando por trás — não precisa
|
|
33
|
+
-- de par restritivo, ao contrário das tabelas novas da 040 e da 045.
|
|
34
|
+
--
|
|
35
|
+
-- Aditiva e idempotente.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
-- Uma policy numa tabela sem RLS ligado é uma mentira: ela existe no catálogo e
|
|
39
|
+
-- não filtra nada. A 001 já liga, então aqui é no-op em qualquer pool de
|
|
40
|
+
-- verdade — e é o que faz este arquivo bastar por si em qualquer outro.
|
|
41
|
+
ALTER TABLE public.tenant_members ENABLE ROW LEVEL SECURITY;
|
|
42
|
+
|
|
43
|
+
DROP POLICY IF EXISTS "members_select" ON public.tenant_members;
|
|
44
|
+
CREATE POLICY "members_select" ON public.tenant_members
|
|
45
|
+
FOR SELECT
|
|
46
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
47
|
+
|
|
48
|
+
-- ── E o nome de quem está na lista ──────────────────────────────────────────
|
|
49
|
+
-- Só ampliar `tenant_members` deixaria a tela pior, não melhor: as linhas dos
|
|
50
|
+
-- colegas passariam a aparecer **em branco**. Quem tem login e não tem ficha é
|
|
51
|
+
-- nomeado a partir de `profiles`, e `profiles_select` da 001 é
|
|
52
|
+
-- `id = auth.uid()` — cada um só lê o próprio nome.
|
|
53
|
+
--
|
|
54
|
+
-- `profiles` tem exatamente id, e-mail, nome e foto. Nome e foto são a razão de
|
|
55
|
+
-- a tabela existir, e o e-mail do colega a tela de equipe já mostra pela ficha
|
|
56
|
+
-- e pelo convite. O que não pode acontecer é ler gente de outra empresa, e é
|
|
57
|
+
-- por isso que o segundo braço passa por `tenant_members` (que acabou de ficar
|
|
58
|
+
-- limitado às minhas contas) em vez de liberar geral.
|
|
59
|
+
--
|
|
60
|
+
-- O braço é uma subconsulta NÃO correlacionada: um InitPlan por consulta, não
|
|
61
|
+
-- uma busca por linha. É a mesma disciplina do predicado de unidade da 042.
|
|
62
|
+
DROP POLICY IF EXISTS "profiles_select" ON public.profiles;
|
|
63
|
+
CREATE POLICY "profiles_select" ON public.profiles
|
|
64
|
+
FOR SELECT
|
|
65
|
+
USING (
|
|
66
|
+
id = (SELECT auth.uid())
|
|
67
|
+
OR id IN (
|
|
68
|
+
SELECT tm.user_id FROM public.tenant_members tm
|
|
69
|
+
WHERE tm.tenant_id IN (SELECT public.user_tenant_ids())
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- O ledger não é confiável em todo pool (há tabela aplicada à mão fora dele),
|
|
74
|
+
-- então a coluna é conferida em vez de presumida.
|
|
75
|
+
DO $$
|
|
76
|
+
BEGIN
|
|
77
|
+
IF EXISTS (
|
|
78
|
+
SELECT 1 FROM information_schema.columns
|
|
79
|
+
WHERE table_schema = 'public' AND table_name = 'tenant_members'
|
|
80
|
+
AND column_name = 'person_id'
|
|
81
|
+
) THEN
|
|
82
|
+
EXECUTE 'COMMENT ON COLUMN public.tenant_members.person_id IS '
|
|
83
|
+
|| quote_literal('O cadastro de pessoa que É este login. Nulo é legítimo: o dono que criou o espaço nunca ganha ficha, e metade de uma equipe de salão nunca faz login.');
|
|
84
|
+
END IF;
|
|
85
|
+
END $$;
|