@fayz-ai/db 0.8.4 → 0.9.0-next.1
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.
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 021_tenant_roles.sql — custom access profiles, per tenant.
|
|
3
|
+
--
|
|
4
|
+
-- The SDK has always been able to BUILD a custom role (duplicate a system role
|
|
5
|
+
-- in Settings → Permissions, or ask the assistant for one), but the table it
|
|
6
|
+
-- writes to never existed outside one legacy pool: `saas_core.tenant_roles`,
|
|
7
|
+
-- swept into `public` by 0000b and created nowhere else. Everywhere else the
|
|
8
|
+
-- insert threw and the screen swallowed it — a "Criar perfil" button that did
|
|
9
|
+
-- nothing.
|
|
10
|
+
--
|
|
11
|
+
-- Two changes, both required for a custom profile to survive a reload:
|
|
12
|
+
--
|
|
13
|
+
-- 1. public.tenant_roles — the role's IDENTITY (key + name). Its grants live
|
|
14
|
+
-- in tenant_role_overrides, keyed by that same `key`.
|
|
15
|
+
--
|
|
16
|
+
-- 2. tenant_role_overrides.permission_id loses its FK to public.permissions.
|
|
17
|
+
-- That catalog is a legacy of DB-driven RBAC; the feature catalog is now
|
|
18
|
+
-- DECLARED IN CODE by plugins (`declaredFeatures`) and merged at runtime,
|
|
19
|
+
-- so an app can grant `crm.leads.read` on a pool whose permissions table
|
|
20
|
+
-- never heard of plugin-crm. The FK made every such grant fail. The column
|
|
21
|
+
-- stays text in `feature.action` form — the shape agent_guard reads.
|
|
22
|
+
--
|
|
23
|
+
-- Idempotent: safe to re-run, no-ops when already applied.
|
|
24
|
+
-- ============================================================================
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS public.tenant_roles (
|
|
27
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
28
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
29
|
+
-- Stable slug the grants and tenant_members.role point at. Renaming the
|
|
30
|
+
-- profile must never orphan its permissions, so the key is not the name.
|
|
31
|
+
key text NOT NULL,
|
|
32
|
+
name text NOT NULL,
|
|
33
|
+
description text,
|
|
34
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
+
UNIQUE(tenant_id, key)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
ALTER TABLE public.tenant_roles ENABLE ROW LEVEL SECURITY;
|
|
39
|
+
|
|
40
|
+
-- Members see their own tenant's roles (the permission screen lists them);
|
|
41
|
+
-- only tenant admins create, rename or delete one.
|
|
42
|
+
DROP POLICY IF EXISTS "tenant_roles_select" ON public.tenant_roles;
|
|
43
|
+
CREATE POLICY "tenant_roles_select" ON public.tenant_roles FOR SELECT
|
|
44
|
+
USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
|
|
45
|
+
|
|
46
|
+
DROP POLICY IF EXISTS "tenant_roles_manage" ON public.tenant_roles;
|
|
47
|
+
CREATE POLICY "tenant_roles_manage" ON public.tenant_roles FOR ALL
|
|
48
|
+
USING (public.is_tenant_admin(tenant_id));
|
|
49
|
+
|
|
50
|
+
GRANT ALL ON public.tenant_roles TO authenticated, service_role;
|
|
51
|
+
|
|
52
|
+
-- The FK, dropped by lookup rather than by name: pools created before the
|
|
53
|
+
-- rename carry the constraint under whatever name Postgres minted.
|
|
54
|
+
DO $$
|
|
55
|
+
DECLARE
|
|
56
|
+
c record;
|
|
57
|
+
BEGIN
|
|
58
|
+
IF to_regclass('public.tenant_role_overrides') IS NULL THEN
|
|
59
|
+
RETURN;
|
|
60
|
+
END IF;
|
|
61
|
+
FOR c IN
|
|
62
|
+
SELECT con.conname
|
|
63
|
+
FROM pg_constraint con
|
|
64
|
+
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
65
|
+
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
66
|
+
JOIN pg_class frel ON frel.oid = con.confrelid
|
|
67
|
+
WHERE nsp.nspname = 'public'
|
|
68
|
+
AND rel.relname = 'tenant_role_overrides'
|
|
69
|
+
AND con.contype = 'f'
|
|
70
|
+
AND frel.relname = 'permissions'
|
|
71
|
+
LOOP
|
|
72
|
+
EXECUTE format('ALTER TABLE public.tenant_role_overrides DROP CONSTRAINT %I', c.conname);
|
|
73
|
+
END LOOP;
|
|
74
|
+
END $$;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 022_invite_acceptance.sql — o convite finalmente vira membro da org.
|
|
3
|
+
--
|
|
4
|
+
-- `createInvite` sempre gravou a linha em `invitations` e mandou o magic link,
|
|
5
|
+
-- mas NADA nunca converteu essa linha em `tenant_members`. O convidado entrava,
|
|
6
|
+
-- `listUserOrgs` voltava vazio, e o autoCreate do OrgProvider (ligado por
|
|
7
|
+
-- padrão) criava uma "My Organization" nova com role `owner` — a org que
|
|
8
|
+
-- convidou caía no chão em silêncio.
|
|
9
|
+
--
|
|
10
|
+
-- Duas mudanças:
|
|
11
|
+
--
|
|
12
|
+
-- 1. public.accept_pending_invitations() — o resgate que faltava. O client
|
|
13
|
+
-- chama logo depois do sign-in, antes de listar as orgs.
|
|
14
|
+
--
|
|
15
|
+
-- A fonte é a linha de `invitations`, NÃO `auth.users.raw_user_meta_data`.
|
|
16
|
+
-- O metadata também carrega tenant_id/role, mas vem de um
|
|
17
|
+
-- `signInWithOtp({ data })` do browser — qualquer um com a anon key se
|
|
18
|
+
-- carimbaria dentro de qualquer tenant. `invitations` é RLS-gated a admin
|
|
19
|
+
-- do tenant, então é o único sinal confiável.
|
|
20
|
+
--
|
|
21
|
+
-- 2. `invites_select` deixa de ser `USING (true)`. Num pool de indústria
|
|
22
|
+
-- compartilhado isso deixava qualquer usuário autenticado ler a lista de
|
|
23
|
+
-- convites de todos os tenants — e-mails inclusive. Agora: os seus
|
|
24
|
+
-- tenants, mais os convites endereçados a você (que o resgate precisa ver).
|
|
25
|
+
--
|
|
26
|
+
-- Idempotente: seguro re-rodar, no-op quando já aplicada.
|
|
27
|
+
-- ============================================================================
|
|
28
|
+
|
|
29
|
+
CREATE OR REPLACE FUNCTION public.accept_pending_invitations()
|
|
30
|
+
RETURNS integer
|
|
31
|
+
LANGUAGE plpgsql
|
|
32
|
+
SECURITY DEFINER
|
|
33
|
+
SET search_path = public
|
|
34
|
+
AS $$
|
|
35
|
+
DECLARE
|
|
36
|
+
v_uid uuid := auth.uid();
|
|
37
|
+
v_email text;
|
|
38
|
+
v_count integer := 0;
|
|
39
|
+
r record;
|
|
40
|
+
BEGIN
|
|
41
|
+
IF v_uid IS NULL THEN
|
|
42
|
+
RETURN 0;
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
-- Lido de auth.users, não do JWT: sobrevive a token sem claim de e-mail.
|
|
46
|
+
SELECT u.email INTO v_email FROM auth.users u WHERE u.id = v_uid;
|
|
47
|
+
IF v_email IS NULL OR v_email = '' THEN
|
|
48
|
+
RETURN 0;
|
|
49
|
+
END IF;
|
|
50
|
+
|
|
51
|
+
FOR r IN
|
|
52
|
+
SELECT i.id, i.tenant_id, i.role, i.location_ids
|
|
53
|
+
FROM public.invitations i
|
|
54
|
+
WHERE lower(i.email) = lower(v_email)
|
|
55
|
+
AND i.status = 'pending'
|
|
56
|
+
AND i.expires_at > now()
|
|
57
|
+
ORDER BY i.created_at
|
|
58
|
+
LOOP
|
|
59
|
+
-- DO NOTHING e não DO UPDATE: um convite nunca troca o papel de quem já é membro.
|
|
60
|
+
INSERT INTO public.tenant_members (tenant_id, user_id, role)
|
|
61
|
+
VALUES (r.tenant_id, v_uid, r.role)
|
|
62
|
+
ON CONFLICT (tenant_id, user_id) DO NOTHING;
|
|
63
|
+
|
|
64
|
+
IF r.location_ids IS NOT NULL AND array_length(r.location_ids, 1) > 0 THEN
|
|
65
|
+
INSERT INTO public.location_members (location_id, user_id, role)
|
|
66
|
+
SELECT l.id, v_uid, r.role
|
|
67
|
+
FROM public.locations l
|
|
68
|
+
WHERE l.id = ANY(r.location_ids) AND l.tenant_id = r.tenant_id
|
|
69
|
+
ON CONFLICT (location_id, user_id) DO NOTHING;
|
|
70
|
+
END IF;
|
|
71
|
+
|
|
72
|
+
UPDATE public.invitations
|
|
73
|
+
SET status = 'accepted', accepted_at = now()
|
|
74
|
+
WHERE id = r.id;
|
|
75
|
+
|
|
76
|
+
v_count := v_count + 1;
|
|
77
|
+
END LOOP;
|
|
78
|
+
|
|
79
|
+
RETURN v_count;
|
|
80
|
+
END; $$;
|
|
81
|
+
|
|
82
|
+
REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM public;
|
|
83
|
+
REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM anon;
|
|
84
|
+
GRANT EXECUTE ON FUNCTION public.accept_pending_invitations() TO authenticated, service_role;
|
|
85
|
+
|
|
86
|
+
DROP POLICY IF EXISTS "invites_select" ON public.invitations;
|
|
87
|
+
CREATE POLICY "invites_select" ON public.invitations FOR SELECT TO authenticated
|
|
88
|
+
USING (
|
|
89
|
+
tenant_id IN (SELECT public.user_tenant_ids())
|
|
90
|
+
OR lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
|
91
|
+
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 023_team_visibility.sql — a equipe finalmente enxerga a equipe.
|
|
3
|
+
--
|
|
4
|
+
-- `001_core` fechou dois SELECTs no próprio usuário:
|
|
5
|
+
--
|
|
6
|
+
-- profiles_select USING (id = auth.uid()) -- "own profile only"
|
|
7
|
+
-- members_select USING (user_id = auth.uid()) -- "see own"
|
|
8
|
+
--
|
|
9
|
+
-- Com isso a tela de Equipe é impossível por construção: nenhum usuário, em
|
|
10
|
+
-- nenhum tenant, jamais vê mais de um membro. O sintoma muda com o modo da
|
|
11
|
+
-- TeamTab, mas a causa é a mesma:
|
|
12
|
+
--
|
|
13
|
+
-- • modo legado (lista vem de `listMembers` → tenant_members): "1 membro
|
|
14
|
+
-- nesta organização", sempre — só a própria linha atravessa a RLS.
|
|
15
|
+
--
|
|
16
|
+
-- • person-mode (lista vem de `people`, com o vínculo sobreposto a partir de
|
|
17
|
+
-- tenant_members): as pessoas aparecem, mas `p.membership` volta undefined
|
|
18
|
+
-- para todo mundo menos você → `hasAccess = false` → a linha exibe
|
|
19
|
+
-- "sem acesso" + botão Convidar MESMO para quem já tem conta e papel.
|
|
20
|
+
-- Quem tem acesso e quem não tem ficam indistinguíveis na tela.
|
|
21
|
+
--
|
|
22
|
+
-- Não é decisão de projeto que valha manter: o resto do próprio 001_core já
|
|
23
|
+
-- usa o padrão tenant-wide (`subs_select`, `invoices_select`,
|
|
24
|
+
-- `loc_members_select` todos fazem tenant_id IN (... WHERE user_id = auth.uid())).
|
|
25
|
+
-- Só members/profiles ficaram no `= auth.uid()`. Esta migration alinha os dois
|
|
26
|
+
-- com o padrão que o core já adota — não inventa política nova.
|
|
27
|
+
--
|
|
28
|
+
-- O alcance é deliberadamente o mínimo que a tela precisa: quem divide tenant
|
|
29
|
+
-- com você passa a ver seu nome, e-mail e avatar. Nada atravessa tenant.
|
|
30
|
+
--
|
|
31
|
+
-- EFEITO COLATERAL ESPERADO — seat cap. O limite 'users'
|
|
32
|
+
-- (limits-registry.ts → { key: 'users', table: 'tenant_members' }) conta linhas
|
|
33
|
+
-- de tenant_members, e hoje essa contagem volta 1 para todo mundo: o cap de
|
|
34
|
+
-- assentos nunca foi de fato aplicado. Depois desta migration ele passa a
|
|
35
|
+
-- contar certo, e tenants acima do plano vão bater no UpgradeModal ao convidar.
|
|
36
|
+
-- É a regra funcionando pela primeira vez, não uma regressão — mas conferir os
|
|
37
|
+
-- planos antes de aplicar em produção evita susto.
|
|
38
|
+
--
|
|
39
|
+
-- Idempotente: seguro re-rodar, no-op quando já aplicada.
|
|
40
|
+
-- ============================================================================
|
|
41
|
+
|
|
42
|
+
-- Os membros dos SEUS tenants, não só você.
|
|
43
|
+
--
|
|
44
|
+
-- `user_tenant_ids()` (002) é SECURITY DEFINER, então roda por fora da RLS e a
|
|
45
|
+
-- policy de tenant_members pode consultar tenant_members sem recursão. Trocar
|
|
46
|
+
-- por um EXISTS inline sobre a mesma tabela reintroduz o loop — não simplifique.
|
|
47
|
+
DROP POLICY IF EXISTS "members_select" ON public.tenant_members;
|
|
48
|
+
CREATE POLICY "members_select" ON public.tenant_members FOR SELECT TO authenticated
|
|
49
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
50
|
+
|
|
51
|
+
-- O profile de quem divide tenant com você. Mesmo motivo do SECURITY DEFINER
|
|
52
|
+
-- acima: a checagem lê tenant_members, que agora tem policy própria.
|
|
53
|
+
CREATE OR REPLACE FUNCTION public.shares_tenant_with(p_user_id uuid)
|
|
54
|
+
RETURNS boolean
|
|
55
|
+
LANGUAGE sql
|
|
56
|
+
STABLE
|
|
57
|
+
SECURITY DEFINER
|
|
58
|
+
SET search_path = public
|
|
59
|
+
AS $$
|
|
60
|
+
SELECT EXISTS (
|
|
61
|
+
SELECT 1
|
|
62
|
+
FROM public.tenant_members mine
|
|
63
|
+
JOIN public.tenant_members theirs ON theirs.tenant_id = mine.tenant_id
|
|
64
|
+
WHERE mine.user_id = auth.uid()
|
|
65
|
+
AND theirs.user_id = p_user_id
|
|
66
|
+
);
|
|
67
|
+
$$;
|
|
68
|
+
|
|
69
|
+
REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM public;
|
|
70
|
+
REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM anon;
|
|
71
|
+
GRANT EXECUTE ON FUNCTION public.shares_tenant_with(uuid) TO authenticated, service_role;
|
|
72
|
+
|
|
73
|
+
DROP POLICY IF EXISTS "profiles_select" ON public.profiles;
|
|
74
|
+
CREATE POLICY "profiles_select" ON public.profiles FOR SELECT TO authenticated
|
|
75
|
+
USING (id = auth.uid() OR public.shares_tenant_with(id));
|
package/package.json
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "beta"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.9.0-next.1",
|
|
7
7
|
"description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
|
|
8
8
|
"type": "module",
|
|
9
|
+
"sideEffects": false,
|
|
9
10
|
"main": "./dist/index.cjs",
|
|
10
11
|
"module": "./dist/index.js",
|
|
11
12
|
"types": "./dist/index.d.ts",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"database",
|
|
38
39
|
"schema"
|
|
39
40
|
],
|
|
41
|
+
"peerDependencies": {},
|
|
40
42
|
"scripts": {
|
|
41
43
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
42
44
|
"dev": "tsup --watch",
|