@fayz-ai/db 0.12.0 → 0.13.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/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 061_create_tenant.sql — one transactional RPC creates a tenant (#134, PRD 01 R7).
|
|
3
|
+
--
|
|
4
|
+
-- public.create_tenant(name, slug, kind, vertical, template, settings, hq_name)
|
|
5
|
+
-- creates, in one transaction and as the SIGNED-IN user: the tenant row, its
|
|
6
|
+
-- system roles from the vertical template (app.seed_role_template), its
|
|
7
|
+
-- headquarters unit and the owner membership (all_units) of the caller. The
|
|
8
|
+
-- actor is auth.uid() — there is no p_user_id — and a call without a session
|
|
9
|
+
-- raises 42501. Nobody can create a tenant on behalf of someone else through
|
|
10
|
+
-- this surface; a platform process acts with a signed token whose `sub` is
|
|
11
|
+
-- the future owner.
|
|
12
|
+
--
|
|
13
|
+
-- public.create_tenant_with_owner(name, slug, p_user_id, …) — the legacy
|
|
14
|
+
-- SECURITY DEFINER function of 001 that trusted p_user_id — is REPLACED by a
|
|
15
|
+
-- shim with the same signature (the admin adapter's rpc keeps working for the
|
|
16
|
+
-- self case) that refuses any p_user_id other than auth.uid() (42501) and
|
|
17
|
+
-- delegates to create_tenant. p_plan is ignored: a plan is assigned by billing,
|
|
18
|
+
-- not chosen by the client. Removal of the shim: Phase 1 (admin adapter on
|
|
19
|
+
-- create_tenant).
|
|
20
|
+
--
|
|
21
|
+
-- Idempotent: CREATE OR REPLACE; explicit REVOKE/GRANT allowlist re-asserted
|
|
22
|
+
-- (001's replay re-grants EXECUTE ON ALL FUNCTIONS to anon; 009 and this file
|
|
23
|
+
-- take it back — the chain converges).
|
|
24
|
+
-- ============================================================================
|
|
25
|
+
|
|
26
|
+
CREATE OR REPLACE FUNCTION public.create_tenant(
|
|
27
|
+
p_name text,
|
|
28
|
+
p_slug text,
|
|
29
|
+
p_kind text DEFAULT 'business',
|
|
30
|
+
p_vertical_id text DEFAULT NULL,
|
|
31
|
+
p_template text DEFAULT 'generic',
|
|
32
|
+
p_settings jsonb DEFAULT '{}'::jsonb,
|
|
33
|
+
p_hq_name text DEFAULT NULL
|
|
34
|
+
)
|
|
35
|
+
RETURNS jsonb
|
|
36
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
37
|
+
SET search_path = ''
|
|
38
|
+
AS $$
|
|
39
|
+
DECLARE
|
|
40
|
+
v_uid uuid := auth.uid();
|
|
41
|
+
v_tenant uuid;
|
|
42
|
+
v_unit uuid;
|
|
43
|
+
v_membership uuid;
|
|
44
|
+
v_owner_role uuid;
|
|
45
|
+
v_template text;
|
|
46
|
+
v_slug text;
|
|
47
|
+
v_name text;
|
|
48
|
+
BEGIN
|
|
49
|
+
IF v_uid IS NULL THEN
|
|
50
|
+
RAISE EXCEPTION 'create_tenant: the actor is derived from the session and there is no session'
|
|
51
|
+
USING ERRCODE = '42501';
|
|
52
|
+
END IF;
|
|
53
|
+
IF p_kind IS NULL OR p_kind NOT IN ('network', 'business') THEN
|
|
54
|
+
RAISE EXCEPTION 'create_tenant: kind must be network or business (got %)', coalesce(p_kind, '<null>')
|
|
55
|
+
USING ERRCODE = '22023';
|
|
56
|
+
END IF;
|
|
57
|
+
v_name := nullif(btrim(coalesce(p_name, '')), '');
|
|
58
|
+
IF v_name IS NULL THEN
|
|
59
|
+
RAISE EXCEPTION 'create_tenant: name is required' USING ERRCODE = '22023';
|
|
60
|
+
END IF;
|
|
61
|
+
v_slug := lower(btrim(coalesce(p_slug, '')));
|
|
62
|
+
IF v_slug !~ '^[a-z0-9]+([._-][a-z0-9]+)*$' OR length(v_slug) > 100 THEN
|
|
63
|
+
RAISE EXCEPTION 'create_tenant: slug must be lowercase letters and digits separated by single . _ - (got %)', coalesce(nullif(v_slug, ''), '<empty>')
|
|
64
|
+
USING ERRCODE = '22023';
|
|
65
|
+
END IF;
|
|
66
|
+
IF EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = v_slug) THEN
|
|
67
|
+
RAISE EXCEPTION 'create_tenant: slug % is already taken', v_slug USING ERRCODE = '23505';
|
|
68
|
+
END IF;
|
|
69
|
+
|
|
70
|
+
-- Template: explicit, else the vertical's own template when one exists, else generic.
|
|
71
|
+
v_template := coalesce(
|
|
72
|
+
p_template,
|
|
73
|
+
CASE WHEN p_vertical_id IS NOT NULL AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = p_vertical_id)
|
|
74
|
+
THEN p_vertical_id ELSE 'generic' END);
|
|
75
|
+
IF NOT EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = v_template) THEN
|
|
76
|
+
RAISE EXCEPTION 'create_tenant: unknown role template %', v_template USING ERRCODE = '22023';
|
|
77
|
+
END IF;
|
|
78
|
+
|
|
79
|
+
INSERT INTO public.tenants (name, slug, plan, vertical_id, kind, settings, role_template)
|
|
80
|
+
VALUES (v_name, v_slug, 'free', p_vertical_id, p_kind, coalesce(p_settings, '{}'::jsonb), v_template)
|
|
81
|
+
RETURNING id INTO v_tenant;
|
|
82
|
+
|
|
83
|
+
v_owner_role := app.seed_role_template(v_tenant, v_template);
|
|
84
|
+
|
|
85
|
+
INSERT INTO app.units (tenant_id, kind, name, slug, active)
|
|
86
|
+
VALUES (v_tenant, 'hq', coalesce(nullif(btrim(coalesce(p_hq_name, '')), ''), v_name), 'hq', true)
|
|
87
|
+
RETURNING id INTO v_unit;
|
|
88
|
+
|
|
89
|
+
-- The owner: role from the template (holds the whole catalog), every unit.
|
|
90
|
+
INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active)
|
|
91
|
+
VALUES (v_tenant, v_uid, v_owner_role, true, true)
|
|
92
|
+
RETURNING id INTO v_membership;
|
|
93
|
+
|
|
94
|
+
RETURN jsonb_build_object(
|
|
95
|
+
'tenant', (SELECT to_jsonb(t) FROM public.tenants t WHERE t.id = v_tenant),
|
|
96
|
+
'hq_unit', (SELECT to_jsonb(u) FROM app.units u WHERE u.id = v_unit),
|
|
97
|
+
'membership', (SELECT to_jsonb(m) FROM app.memberships m WHERE m.id = v_membership)
|
|
98
|
+
);
|
|
99
|
+
END $$;
|
|
100
|
+
REVOKE ALL ON FUNCTION public.create_tenant(text, text, text, text, text, jsonb, text) FROM public, anon;
|
|
101
|
+
GRANT EXECUTE ON FUNCTION public.create_tenant(text, text, text, text, text, jsonb, text) TO authenticated, service_role;
|
|
102
|
+
|
|
103
|
+
-- ── the legacy entry point, now a shim ──────────────────────────────────
|
|
104
|
+
CREATE OR REPLACE FUNCTION public.create_tenant_with_owner(
|
|
105
|
+
p_name text, p_slug text, p_user_id uuid,
|
|
106
|
+
p_vertical_id text DEFAULT NULL, p_plan text DEFAULT 'free', p_settings jsonb DEFAULT '{}'
|
|
107
|
+
)
|
|
108
|
+
RETURNS jsonb
|
|
109
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
110
|
+
SET search_path = ''
|
|
111
|
+
AS $$
|
|
112
|
+
DECLARE v_result jsonb;
|
|
113
|
+
BEGIN
|
|
114
|
+
IF auth.uid() IS NULL OR p_user_id IS DISTINCT FROM auth.uid() THEN
|
|
115
|
+
RAISE EXCEPTION 'create_tenant_with_owner: the actor is derived from the session; p_user_id must be the caller'
|
|
116
|
+
USING ERRCODE = '42501';
|
|
117
|
+
END IF;
|
|
118
|
+
-- p_plan is deliberately not honoured: billing assigns plans.
|
|
119
|
+
v_result := public.create_tenant(p_name, p_slug, 'business', p_vertical_id, NULL, p_settings, NULL);
|
|
120
|
+
RETURN v_result -> 'tenant';
|
|
121
|
+
END $$;
|
|
122
|
+
COMMENT ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) IS
|
|
123
|
+
'DEPRECATED (#134): shim over public.create_tenant. Refuses p_user_id <> auth.uid() (42501); p_plan ignored. Removal: Phase 1.';
|
|
124
|
+
REVOKE ALL ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) FROM public, anon;
|
|
125
|
+
GRANT EXECUTE ON FUNCTION public.create_tenant_with_owner(text, text, uuid, text, text, jsonb) TO authenticated, service_role;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 062_invitations.sql — the transactional invite flow onto app.* (#134, PRD 01 R7).
|
|
3
|
+
--
|
|
4
|
+
-- public.invitations stays the table (the admin adapter reads and writes it by
|
|
5
|
+
-- name) and grows the columns the new flow needs:
|
|
6
|
+
-- role_id → app.roles the invited role (the legacy `role` text key is kept in step)
|
|
7
|
+
-- unit_ids[] / all_units the unit scope of the future membership
|
|
8
|
+
-- token_hash sha256 of the bearer token; the token itself is
|
|
9
|
+
-- returned ONCE by invite_member and never stored
|
|
10
|
+
-- accepted_by who consumed it
|
|
11
|
+
-- The legacy `token` column (a random string readable by every member of the
|
|
12
|
+
-- tenant) is retired: no default, nullable, nulled — nothing ever consumed it.
|
|
13
|
+
--
|
|
14
|
+
-- RPCs (SECURITY DEFINER, search_path '', actor = auth.uid(), tenant =
|
|
15
|
+
-- app.current_tenant_id(), permission = app.has_permission):
|
|
16
|
+
-- invite_member(email, role_key, unit_ids, all_units, ttl) → {invitation_id, token, …}
|
|
17
|
+
-- needs members.manage; the invited role's permission set must be a subset of
|
|
18
|
+
-- the inviter's (no privilege escalation by invitation); the token is what the
|
|
19
|
+
-- e-mail carries.
|
|
20
|
+
-- accept_invitation(token) → {tenant_id, membership_id, role_key, units, all_units}
|
|
21
|
+
-- single-use, expiring, bound to the e-mail it was issued to (auth.users.email
|
|
22
|
+
-- of the caller must match, case-insensitively — a forwarded link is not an
|
|
23
|
+
-- invitation); creates the membership + membership_units in one transaction;
|
|
24
|
+
-- the fan-out triggers of 042 fill user_unit_access.
|
|
25
|
+
-- accept_pending_invitations() → integer (re-authored from 022, same contract)
|
|
26
|
+
-- converts every pending, non-expired invitation addressed to the caller's
|
|
27
|
+
-- e-mail into a membership. Called by the admin adapter right after sign-in.
|
|
28
|
+
--
|
|
29
|
+
-- All denials are ERRCODE 42501 with an English message; malformed input is 22023.
|
|
30
|
+
-- Idempotent and replay-safe.
|
|
31
|
+
-- ============================================================================
|
|
32
|
+
|
|
33
|
+
-- ── columns ─────────────────────────────────────────────────────────────
|
|
34
|
+
ALTER TABLE public.invitations
|
|
35
|
+
ADD COLUMN IF NOT EXISTS role_id uuid,
|
|
36
|
+
ADD COLUMN IF NOT EXISTS unit_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
|
37
|
+
ADD COLUMN IF NOT EXISTS all_units boolean NOT NULL DEFAULT false,
|
|
38
|
+
ADD COLUMN IF NOT EXISTS token_hash text,
|
|
39
|
+
ADD COLUMN IF NOT EXISTS accepted_by uuid;
|
|
40
|
+
DO $$
|
|
41
|
+
BEGIN
|
|
42
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invitations_role_id_fkey' AND conrelid = 'public.invitations'::regclass) THEN
|
|
43
|
+
ALTER TABLE public.invitations
|
|
44
|
+
ADD CONSTRAINT invitations_role_id_fkey FOREIGN KEY (role_id) REFERENCES app.roles(id) ON DELETE SET NULL;
|
|
45
|
+
END IF;
|
|
46
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invitations_status_check' AND conrelid = 'public.invitations'::regclass) THEN
|
|
47
|
+
ALTER TABLE public.invitations
|
|
48
|
+
ADD CONSTRAINT invitations_status_check CHECK (status IN ('pending', 'accepted', 'revoked', 'expired'));
|
|
49
|
+
END IF;
|
|
50
|
+
END $$;
|
|
51
|
+
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_hash_uidx ON public.invitations (token_hash) WHERE token_hash IS NOT NULL;
|
|
52
|
+
CREATE INDEX IF NOT EXISTS invitations_pending_email_idx ON public.invitations (lower(email)) WHERE status = 'pending';
|
|
53
|
+
|
|
54
|
+
-- The legacy bearer column is retired: it was never consumed by anything in the
|
|
55
|
+
-- repo (accept always matched by e-mail) and a raw random secret readable by
|
|
56
|
+
-- every member of the tenant is not a credential we keep.
|
|
57
|
+
ALTER TABLE public.invitations ALTER COLUMN token DROP NOT NULL;
|
|
58
|
+
ALTER TABLE public.invitations ALTER COLUMN token DROP DEFAULT;
|
|
59
|
+
UPDATE public.invitations SET token = NULL WHERE token IS NOT NULL;
|
|
60
|
+
|
|
61
|
+
-- ── normalisation trigger: both column sets stay coherent ───────────────
|
|
62
|
+
-- Legacy writers (the admin adapter) insert (tenant_id, email, role, location_ids);
|
|
63
|
+
-- the RPC inserts (role_id, unit_ids, token_hash). Whichever side is written,
|
|
64
|
+
-- the other is derived so every reader sees the same invitation.
|
|
65
|
+
CREATE OR REPLACE FUNCTION public.trg_invitations_normalize()
|
|
66
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
67
|
+
DECLARE v_key text;
|
|
68
|
+
BEGIN
|
|
69
|
+
NEW.email := lower(btrim(coalesce(NEW.email, '')));
|
|
70
|
+
IF NEW.email = '' THEN
|
|
71
|
+
RAISE EXCEPTION 'invitations: email is required' USING ERRCODE = '22023';
|
|
72
|
+
END IF;
|
|
73
|
+
NEW.token := NULL;
|
|
74
|
+
|
|
75
|
+
-- role: whichever side was written wins; validated on insert and on change
|
|
76
|
+
IF TG_OP = 'INSERT' OR NEW.role_id IS DISTINCT FROM OLD.role_id OR NEW.role IS DISTINCT FROM OLD.role THEN
|
|
77
|
+
IF NEW.role_id IS NOT NULL AND (TG_OP = 'INSERT' OR NEW.role_id IS DISTINCT FROM OLD.role_id OR NEW.role IS NOT DISTINCT FROM OLD.role) THEN
|
|
78
|
+
SELECT r.key INTO v_key FROM app.roles r WHERE r.id = NEW.role_id AND r.tenant_id = NEW.tenant_id;
|
|
79
|
+
IF v_key IS NULL THEN
|
|
80
|
+
RAISE EXCEPTION 'invitations: role % does not belong to this tenant', NEW.role_id USING ERRCODE = '22023';
|
|
81
|
+
END IF;
|
|
82
|
+
NEW.role := v_key;
|
|
83
|
+
ELSIF NEW.role IS NOT NULL THEN
|
|
84
|
+
SELECT r.id INTO NEW.role_id FROM app.roles r WHERE r.tenant_id = NEW.tenant_id AND r.key = NEW.role;
|
|
85
|
+
IF NEW.role_id IS NULL THEN
|
|
86
|
+
RAISE EXCEPTION 'invitations: unknown role key % in this tenant', NEW.role USING ERRCODE = '22023';
|
|
87
|
+
END IF;
|
|
88
|
+
END IF;
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
-- unit scope: units are the successors of locations (same ids); keep both in step
|
|
92
|
+
IF TG_OP = 'INSERT' THEN
|
|
93
|
+
IF cardinality(coalesce(NEW.unit_ids, '{}'::uuid[])) = 0 AND cardinality(coalesce(NEW.location_ids, '{}'::uuid[])) > 0 THEN
|
|
94
|
+
NEW.unit_ids := NEW.location_ids;
|
|
95
|
+
ELSE
|
|
96
|
+
NEW.location_ids := coalesce(NEW.unit_ids, '{}'::uuid[]);
|
|
97
|
+
END IF;
|
|
98
|
+
ELSIF NEW.unit_ids IS DISTINCT FROM OLD.unit_ids THEN
|
|
99
|
+
NEW.location_ids := coalesce(NEW.unit_ids, '{}'::uuid[]);
|
|
100
|
+
ELSIF NEW.location_ids IS DISTINCT FROM OLD.location_ids THEN
|
|
101
|
+
NEW.unit_ids := coalesce(NEW.location_ids, '{}'::uuid[]);
|
|
102
|
+
END IF;
|
|
103
|
+
IF (TG_OP = 'INSERT' OR NEW.unit_ids IS DISTINCT FROM OLD.unit_ids)
|
|
104
|
+
AND EXISTS (SELECT 1 FROM unnest(NEW.unit_ids) x
|
|
105
|
+
WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x AND u.tenant_id = NEW.tenant_id)) THEN
|
|
106
|
+
RAISE EXCEPTION 'invitations: every unit must belong to the tenant' USING ERRCODE = '22023';
|
|
107
|
+
END IF;
|
|
108
|
+
RETURN NEW;
|
|
109
|
+
END $$;
|
|
110
|
+
REVOKE ALL ON FUNCTION public.trg_invitations_normalize() FROM public, anon, authenticated;
|
|
111
|
+
DROP TRIGGER IF EXISTS invitations_normalize ON public.invitations;
|
|
112
|
+
CREATE TRIGGER invitations_normalize BEFORE INSERT OR UPDATE ON public.invitations
|
|
113
|
+
FOR EACH ROW EXECUTE FUNCTION public.trg_invitations_normalize();
|
|
114
|
+
|
|
115
|
+
-- ── policies (re-authored: platform permission keys, no admin literal) ──
|
|
116
|
+
DROP POLICY IF EXISTS "invites_select" ON public.invitations;
|
|
117
|
+
CREATE POLICY "invites_select" ON public.invitations FOR SELECT TO authenticated
|
|
118
|
+
USING (
|
|
119
|
+
(tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read')))
|
|
120
|
+
OR lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
|
121
|
+
);
|
|
122
|
+
DROP POLICY IF EXISTS "invites_insert" ON public.invitations;
|
|
123
|
+
CREATE POLICY "invites_insert" ON public.invitations FOR INSERT TO authenticated
|
|
124
|
+
WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
|
|
125
|
+
DROP POLICY IF EXISTS "invites_update" ON public.invitations;
|
|
126
|
+
CREATE POLICY "invites_update" ON public.invitations FOR UPDATE TO authenticated
|
|
127
|
+
USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
|
|
128
|
+
WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
|
|
129
|
+
DROP POLICY IF EXISTS "invites_delete" ON public.invitations;
|
|
130
|
+
CREATE POLICY "invites_delete" ON public.invitations FOR DELETE TO authenticated
|
|
131
|
+
USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
|
|
132
|
+
|
|
133
|
+
-- ── the shared acceptance step ──────────────────────────────────────────
|
|
134
|
+
-- Applies one invitation to one user: membership (an active member keeps her
|
|
135
|
+
-- role — an invitation never demotes or promotes an existing member, 022's
|
|
136
|
+
-- rule; an inactive membership is reactivated with the invited role), the
|
|
137
|
+
-- invited units, the audit stamp. Internal: not granted to any API role.
|
|
138
|
+
CREATE OR REPLACE FUNCTION app.apply_invitation(p_invitation uuid, p_user uuid)
|
|
139
|
+
RETURNS jsonb
|
|
140
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
141
|
+
DECLARE
|
|
142
|
+
inv record;
|
|
143
|
+
v_role uuid;
|
|
144
|
+
v_membership uuid;
|
|
145
|
+
BEGIN
|
|
146
|
+
SELECT * INTO inv FROM public.invitations i WHERE i.id = p_invitation FOR UPDATE;
|
|
147
|
+
IF NOT FOUND THEN
|
|
148
|
+
RAISE EXCEPTION 'invitation not found' USING ERRCODE = '42501';
|
|
149
|
+
END IF;
|
|
150
|
+
v_role := inv.role_id;
|
|
151
|
+
IF v_role IS NULL AND inv.role IS NOT NULL THEN
|
|
152
|
+
SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = inv.tenant_id AND r.key = inv.role;
|
|
153
|
+
END IF;
|
|
154
|
+
IF v_role IS NULL THEN
|
|
155
|
+
RAISE EXCEPTION 'invitation role no longer exists in the tenant' USING ERRCODE = '22023';
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
INSERT INTO app.memberships (tenant_id, user_id, role_id, all_units, active, invited_by)
|
|
159
|
+
VALUES (inv.tenant_id, p_user, v_role, coalesce(inv.all_units, false), true, inv.invited_by)
|
|
160
|
+
ON CONFLICT (tenant_id, user_id) DO UPDATE
|
|
161
|
+
SET active = true,
|
|
162
|
+
role_id = CASE WHEN app.memberships.active THEN app.memberships.role_id ELSE EXCLUDED.role_id END,
|
|
163
|
+
all_units = app.memberships.all_units OR EXCLUDED.all_units,
|
|
164
|
+
invited_by = coalesce(app.memberships.invited_by, EXCLUDED.invited_by)
|
|
165
|
+
RETURNING id INTO v_membership;
|
|
166
|
+
|
|
167
|
+
INSERT INTO app.membership_units (tenant_id, user_id, unit_id)
|
|
168
|
+
SELECT inv.tenant_id, p_user, u.id
|
|
169
|
+
FROM app.units u
|
|
170
|
+
WHERE u.tenant_id = inv.tenant_id AND u.id = ANY (coalesce(inv.unit_ids, '{}'::uuid[]))
|
|
171
|
+
ON CONFLICT DO NOTHING;
|
|
172
|
+
|
|
173
|
+
UPDATE public.invitations
|
|
174
|
+
SET status = 'accepted', accepted_at = now(), accepted_by = p_user
|
|
175
|
+
WHERE id = inv.id;
|
|
176
|
+
|
|
177
|
+
RETURN jsonb_build_object(
|
|
178
|
+
'invitation_id', inv.id,
|
|
179
|
+
'tenant_id', inv.tenant_id,
|
|
180
|
+
'membership_id', v_membership,
|
|
181
|
+
'role_key', (SELECT r.key FROM app.roles r WHERE r.id = (SELECT m.role_id FROM app.memberships m WHERE m.id = v_membership)),
|
|
182
|
+
'all_units', (SELECT m.all_units FROM app.memberships m WHERE m.id = v_membership),
|
|
183
|
+
'units', coalesce((SELECT jsonb_agg(mu.unit_id ORDER BY mu.unit_id) FROM app.membership_units mu
|
|
184
|
+
WHERE mu.tenant_id = inv.tenant_id AND mu.user_id = p_user), '[]'::jsonb)
|
|
185
|
+
);
|
|
186
|
+
END $$;
|
|
187
|
+
REVOKE ALL ON FUNCTION app.apply_invitation(uuid, uuid) FROM public, anon, authenticated;
|
|
188
|
+
|
|
189
|
+
-- ── invite_member ───────────────────────────────────────────────────────
|
|
190
|
+
CREATE OR REPLACE FUNCTION public.invite_member(
|
|
191
|
+
p_email text,
|
|
192
|
+
p_role_key text,
|
|
193
|
+
p_unit_ids uuid[] DEFAULT '{}'::uuid[],
|
|
194
|
+
p_all_units boolean DEFAULT false,
|
|
195
|
+
p_ttl interval DEFAULT '7 days'
|
|
196
|
+
)
|
|
197
|
+
RETURNS jsonb
|
|
198
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
199
|
+
DECLARE
|
|
200
|
+
v_uid uuid := auth.uid();
|
|
201
|
+
v_tenant uuid := app.current_tenant_id();
|
|
202
|
+
v_role uuid;
|
|
203
|
+
v_email text;
|
|
204
|
+
v_token text;
|
|
205
|
+
v_ttl interval := coalesce(p_ttl, interval '7 days');
|
|
206
|
+
v_id uuid;
|
|
207
|
+
v_expires timestamptz;
|
|
208
|
+
BEGIN
|
|
209
|
+
IF v_uid IS NULL OR v_tenant IS NULL THEN
|
|
210
|
+
RAISE EXCEPTION 'invite_member: no session or no active tenant' USING ERRCODE = '42501';
|
|
211
|
+
END IF;
|
|
212
|
+
IF NOT app.has_permission('members.manage') THEN
|
|
213
|
+
RAISE EXCEPTION 'invite_member: members.manage is required' USING ERRCODE = '42501';
|
|
214
|
+
END IF;
|
|
215
|
+
v_email := lower(btrim(coalesce(p_email, '')));
|
|
216
|
+
IF v_email !~ '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' THEN
|
|
217
|
+
RAISE EXCEPTION 'invite_member: invalid e-mail' USING ERRCODE = '22023';
|
|
218
|
+
END IF;
|
|
219
|
+
SELECT r.id INTO v_role FROM app.roles r WHERE r.tenant_id = v_tenant AND r.key = p_role_key;
|
|
220
|
+
IF v_role IS NULL THEN
|
|
221
|
+
RAISE EXCEPTION 'invite_member: unknown role %', coalesce(p_role_key, '<null>') USING ERRCODE = '22023';
|
|
222
|
+
END IF;
|
|
223
|
+
-- No escalation by invitation: every permission of the invited role must be
|
|
224
|
+
-- held by the inviter (base role, tenant-wide).
|
|
225
|
+
IF EXISTS (SELECT 1 FROM app.role_permissions rp
|
|
226
|
+
WHERE rp.tenant_id = v_tenant AND rp.role_id = v_role
|
|
227
|
+
AND NOT app.has_permission(rp.permission)) THEN
|
|
228
|
+
RAISE EXCEPTION 'invite_member: cannot invite to a role holding permissions you do not have' USING ERRCODE = '42501';
|
|
229
|
+
END IF;
|
|
230
|
+
IF EXISTS (SELECT 1 FROM unnest(coalesce(p_unit_ids, '{}'::uuid[])) x
|
|
231
|
+
WHERE NOT EXISTS (SELECT 1 FROM app.units u WHERE u.id = x AND u.tenant_id = v_tenant)) THEN
|
|
232
|
+
RAISE EXCEPTION 'invite_member: every unit must belong to the current tenant' USING ERRCODE = '22023';
|
|
233
|
+
END IF;
|
|
234
|
+
IF v_ttl <= interval '0' OR v_ttl > interval '30 days' THEN
|
|
235
|
+
RAISE EXCEPTION 'invite_member: ttl must be between 1 second and 30 days' USING ERRCODE = '22023';
|
|
236
|
+
END IF;
|
|
237
|
+
|
|
238
|
+
-- 256 bits from the CSPRNG behind gen_random_uuid(); only its sha256 is stored.
|
|
239
|
+
v_token := replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '');
|
|
240
|
+
INSERT INTO public.invitations
|
|
241
|
+
(tenant_id, email, role, role_id, unit_ids, location_ids, all_units, token_hash, status, invited_by, expires_at)
|
|
242
|
+
VALUES (v_tenant, v_email, p_role_key, v_role, coalesce(p_unit_ids, '{}'::uuid[]), coalesce(p_unit_ids, '{}'::uuid[]),
|
|
243
|
+
coalesce(p_all_units, false), encode(sha256(convert_to(v_token, 'UTF8')), 'hex'), 'pending', v_uid, now() + v_ttl)
|
|
244
|
+
RETURNING id, expires_at INTO v_id, v_expires;
|
|
245
|
+
|
|
246
|
+
RETURN jsonb_build_object('invitation_id', v_id, 'token', v_token, 'email', v_email,
|
|
247
|
+
'role_key', p_role_key, 'expires_at', v_expires);
|
|
248
|
+
END $$;
|
|
249
|
+
REVOKE ALL ON FUNCTION public.invite_member(text, text, uuid[], boolean, interval) FROM public, anon;
|
|
250
|
+
GRANT EXECUTE ON FUNCTION public.invite_member(text, text, uuid[], boolean, interval) TO authenticated, service_role;
|
|
251
|
+
|
|
252
|
+
-- ── accept_invitation ───────────────────────────────────────────────────
|
|
253
|
+
CREATE OR REPLACE FUNCTION public.accept_invitation(p_token text)
|
|
254
|
+
RETURNS jsonb
|
|
255
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
256
|
+
DECLARE
|
|
257
|
+
v_uid uuid := auth.uid();
|
|
258
|
+
v_email text;
|
|
259
|
+
inv record;
|
|
260
|
+
BEGIN
|
|
261
|
+
IF v_uid IS NULL THEN
|
|
262
|
+
RAISE EXCEPTION 'accept_invitation: no session' USING ERRCODE = '42501';
|
|
263
|
+
END IF;
|
|
264
|
+
IF nullif(btrim(coalesce(p_token, '')), '') IS NULL THEN
|
|
265
|
+
RAISE EXCEPTION 'accept_invitation: invitation not found or not usable' USING ERRCODE = '42501';
|
|
266
|
+
END IF;
|
|
267
|
+
SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = v_uid;
|
|
268
|
+
|
|
269
|
+
SELECT i.id, i.email, i.status, i.accepted_at, i.expires_at INTO inv
|
|
270
|
+
FROM public.invitations i
|
|
271
|
+
WHERE i.token_hash = encode(sha256(convert_to(btrim(p_token), 'UTF8')), 'hex')
|
|
272
|
+
FOR UPDATE;
|
|
273
|
+
IF NOT FOUND THEN
|
|
274
|
+
RAISE EXCEPTION 'accept_invitation: invitation not found or not usable' USING ERRCODE = '42501';
|
|
275
|
+
END IF;
|
|
276
|
+
IF inv.status <> 'pending' OR inv.accepted_at IS NOT NULL THEN
|
|
277
|
+
RAISE EXCEPTION 'accept_invitation: invitation already used or revoked' USING ERRCODE = '42501';
|
|
278
|
+
END IF;
|
|
279
|
+
IF inv.expires_at <= now() THEN
|
|
280
|
+
RAISE EXCEPTION 'accept_invitation: invitation expired' USING ERRCODE = '42501';
|
|
281
|
+
END IF;
|
|
282
|
+
IF v_email IS NULL OR v_email <> lower(inv.email) THEN
|
|
283
|
+
RAISE EXCEPTION 'accept_invitation: invitation was issued to a different e-mail' USING ERRCODE = '42501';
|
|
284
|
+
END IF;
|
|
285
|
+
|
|
286
|
+
RETURN app.apply_invitation(inv.id, v_uid);
|
|
287
|
+
END $$;
|
|
288
|
+
REVOKE ALL ON FUNCTION public.accept_invitation(text) FROM public, anon;
|
|
289
|
+
GRANT EXECUTE ON FUNCTION public.accept_invitation(text) TO authenticated, service_role;
|
|
290
|
+
|
|
291
|
+
-- ── accept_pending_invitations (022 re-authored, same contract) ─────────
|
|
292
|
+
CREATE OR REPLACE FUNCTION public.accept_pending_invitations()
|
|
293
|
+
RETURNS integer
|
|
294
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
295
|
+
DECLARE
|
|
296
|
+
v_uid uuid := auth.uid();
|
|
297
|
+
v_email text;
|
|
298
|
+
v_count integer := 0;
|
|
299
|
+
r record;
|
|
300
|
+
BEGIN
|
|
301
|
+
IF v_uid IS NULL THEN
|
|
302
|
+
RETURN 0;
|
|
303
|
+
END IF;
|
|
304
|
+
-- From auth.users, not the JWT: survives a token without an e-mail claim.
|
|
305
|
+
SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = v_uid;
|
|
306
|
+
IF v_email IS NULL OR v_email = '' THEN
|
|
307
|
+
RETURN 0;
|
|
308
|
+
END IF;
|
|
309
|
+
FOR r IN
|
|
310
|
+
SELECT i.id
|
|
311
|
+
FROM public.invitations i
|
|
312
|
+
WHERE lower(i.email) = v_email
|
|
313
|
+
AND i.status = 'pending'
|
|
314
|
+
AND i.accepted_at IS NULL
|
|
315
|
+
AND i.expires_at > now()
|
|
316
|
+
ORDER BY i.created_at, i.id
|
|
317
|
+
LOOP
|
|
318
|
+
BEGIN
|
|
319
|
+
PERFORM app.apply_invitation(r.id, v_uid);
|
|
320
|
+
v_count := v_count + 1;
|
|
321
|
+
EXCEPTION WHEN SQLSTATE '22023' THEN
|
|
322
|
+
-- a role that no longer exists: leave the invitation pending for an admin to fix
|
|
323
|
+
RAISE NOTICE 'accept_pending_invitations: skipped invitation % (%)', r.id, SQLERRM;
|
|
324
|
+
END;
|
|
325
|
+
END LOOP;
|
|
326
|
+
RETURN v_count;
|
|
327
|
+
END $$;
|
|
328
|
+
REVOKE ALL ON FUNCTION public.accept_pending_invitations() FROM public, anon;
|
|
329
|
+
GRANT EXECUTE ON FUNCTION public.accept_pending_invitations() TO authenticated, service_role;
|