@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,402 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 053_authz_catalog_templates.sql — the platform permission catalog, the owner
|
|
3
|
+
-- role that never lags the catalog, vertical role templates, and the business
|
|
4
|
+
-- table scaffold (§4/§7 template as one idempotent function).
|
|
5
|
+
--
|
|
6
|
+
-- A new access rule is a row, not a migration (C6): permissions are seeded by
|
|
7
|
+
-- app.seed_permissions(jsonb) — the platform here, each plugin from its
|
|
8
|
+
-- declaredFeatures (#136) — and role templates are rows in app.role_templates
|
|
9
|
+
-- that app.seed_role_template(tenant, template) instantiates as system roles.
|
|
10
|
+
-- ============================================================================
|
|
11
|
+
|
|
12
|
+
-- ── seed_permissions(jsonb) ─────────────────────────────────────────────
|
|
13
|
+
-- [{key, plugin, description}] upserted. Called by the platform (below) and by
|
|
14
|
+
-- plugin migrations. Idempotent; never removes a key (removal is a decision).
|
|
15
|
+
CREATE OR REPLACE FUNCTION app.seed_permissions(p_rows jsonb)
|
|
16
|
+
RETURNS integer
|
|
17
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
18
|
+
AS $$
|
|
19
|
+
DECLARE v_n integer;
|
|
20
|
+
BEGIN
|
|
21
|
+
WITH ins AS (
|
|
22
|
+
INSERT INTO app.permissions (key, plugin, description)
|
|
23
|
+
SELECT r->>'key', r->>'plugin', r->>'description'
|
|
24
|
+
FROM jsonb_array_elements(p_rows) r
|
|
25
|
+
ON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = coalesce(EXCLUDED.description, app.permissions.description)
|
|
26
|
+
RETURNING 1
|
|
27
|
+
) SELECT count(*) INTO v_n FROM ins;
|
|
28
|
+
RETURN v_n;
|
|
29
|
+
END $$;
|
|
30
|
+
REVOKE ALL ON FUNCTION app.seed_permissions(jsonb) FROM public, anon, authenticated;
|
|
31
|
+
GRANT EXECUTE ON FUNCTION app.seed_permissions(jsonb) TO service_role;
|
|
32
|
+
|
|
33
|
+
-- ── the owner role never lags the catalog ───────────────────────────────
|
|
34
|
+
-- "Owner" is a role with a permission set, not a bypass. So that a plugin
|
|
35
|
+
-- installed tomorrow does not leave every owner without its keys, a new catalog
|
|
36
|
+
-- key is granted to every tenant's system `owner` role on insert.
|
|
37
|
+
CREATE OR REPLACE FUNCTION app.trg_permissions_grant_owner()
|
|
38
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
39
|
+
BEGIN
|
|
40
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
41
|
+
SELECT r.tenant_id, r.id, NEW.key
|
|
42
|
+
FROM app.roles r
|
|
43
|
+
WHERE r.is_system AND r.key = 'owner'
|
|
44
|
+
ON CONFLICT DO NOTHING;
|
|
45
|
+
RETURN NULL;
|
|
46
|
+
END $$;
|
|
47
|
+
DROP TRIGGER IF EXISTS permissions_grant_owner ON app.permissions;
|
|
48
|
+
CREATE TRIGGER permissions_grant_owner
|
|
49
|
+
AFTER INSERT ON app.permissions
|
|
50
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_permissions_grant_owner();
|
|
51
|
+
|
|
52
|
+
-- ── platform catalog (core-A keys) ──────────────────────────────────────
|
|
53
|
+
-- '{feature}.{action}' with actions read/create/edit/delete/manage(+read_all
|
|
54
|
+
-- where owner scoping exists). Plugins add theirs (#136 seeds declaredFeatures).
|
|
55
|
+
SELECT app.seed_permissions('[
|
|
56
|
+
{"key":"tenant.read","plugin":"platform","description":"See the tenant"},
|
|
57
|
+
{"key":"tenant.manage","plugin":"platform","description":"Change tenant settings, name, plan"},
|
|
58
|
+
{"key":"units.read","plugin":"platform","description":"List units"},
|
|
59
|
+
{"key":"units.manage","plugin":"platform","description":"Create, move, deactivate units"},
|
|
60
|
+
{"key":"members.read","plugin":"platform","description":"See who is a member and where"},
|
|
61
|
+
{"key":"members.manage","plugin":"platform","description":"Invite, change role/units, deactivate members"},
|
|
62
|
+
{"key":"roles.read","plugin":"platform","description":"See roles and their permissions"},
|
|
63
|
+
{"key":"roles.manage","plugin":"platform","description":"Create and edit tenant roles"},
|
|
64
|
+
{"key":"authz.grants.manage","plugin":"platform","description":"Write resource grants for any resource type"},
|
|
65
|
+
{"key":"settings.read","plugin":"platform","description":"Read tenant settings"},
|
|
66
|
+
{"key":"settings.manage","plugin":"platform","description":"Change tenant settings"},
|
|
67
|
+
{"key":"billing.manage","plugin":"platform","description":"Plans and payment"},
|
|
68
|
+
{"key":"audit.read","plugin":"platform","description":"Read the audit log"},
|
|
69
|
+
{"key":"reports.read","plugin":"reports","description":"Read reports"},
|
|
70
|
+
|
|
71
|
+
{"key":"people.read","plugin":"people","description":"Read people in scope"},
|
|
72
|
+
{"key":"people.read_all","plugin":"people","description":"Read every person of the unit regardless of owner"},
|
|
73
|
+
{"key":"people.create","plugin":"people"},{"key":"people.edit","plugin":"people"},{"key":"people.delete","plugin":"people"},{"key":"people.manage","plugin":"people"},
|
|
74
|
+
|
|
75
|
+
{"key":"catalog.read","plugin":"catalog"},{"key":"catalog.create","plugin":"catalog"},{"key":"catalog.edit","plugin":"catalog"},{"key":"catalog.delete","plugin":"catalog"},{"key":"catalog.manage","plugin":"catalog"},
|
|
76
|
+
|
|
77
|
+
{"key":"orders.read","plugin":"orders"},{"key":"orders.read_all","plugin":"orders"},{"key":"orders.create","plugin":"orders"},{"key":"orders.edit","plugin":"orders"},{"key":"orders.delete","plugin":"orders"},{"key":"orders.manage","plugin":"orders"},
|
|
78
|
+
|
|
79
|
+
{"key":"agenda.read","plugin":"agenda"},{"key":"agenda.read_all","plugin":"agenda"},{"key":"agenda.create","plugin":"agenda"},{"key":"agenda.edit","plugin":"agenda"},{"key":"agenda.delete","plugin":"agenda"},{"key":"agenda.manage","plugin":"agenda"},
|
|
80
|
+
|
|
81
|
+
{"key":"financial.read","plugin":"financial"},{"key":"financial.read_all","plugin":"financial"},{"key":"financial.create","plugin":"financial"},{"key":"financial.edit","plugin":"financial"},{"key":"financial.delete","plugin":"financial"},{"key":"financial.manage","plugin":"financial"},
|
|
82
|
+
{"key":"financial.cash_session","plugin":"financial","description":"Open/close a cash session (caixinha)"},
|
|
83
|
+
|
|
84
|
+
{"key":"inventory.read","plugin":"inventory"},{"key":"inventory.create","plugin":"inventory"},{"key":"inventory.edit","plugin":"inventory"},{"key":"inventory.delete","plugin":"inventory"},{"key":"inventory.manage","plugin":"inventory"}
|
|
85
|
+
]'::jsonb);
|
|
86
|
+
|
|
87
|
+
-- ── role templates (system roles per vertical) ──────────────────────────
|
|
88
|
+
-- Rows, not code: (template, role_key, role_name, permission). The `owner`
|
|
89
|
+
-- role of every template holds the whole catalog (granted at seed time and kept
|
|
90
|
+
-- current by the trigger above), so it needs no rows here.
|
|
91
|
+
CREATE TABLE IF NOT EXISTS app.role_templates (
|
|
92
|
+
template text NOT NULL,
|
|
93
|
+
role_key text NOT NULL,
|
|
94
|
+
role_name text NOT NULL,
|
|
95
|
+
sort_order integer NOT NULL DEFAULT 0,
|
|
96
|
+
permission text NOT NULL REFERENCES app.permissions(key) ON DELETE CASCADE,
|
|
97
|
+
PRIMARY KEY (template, role_key, permission)
|
|
98
|
+
);
|
|
99
|
+
ALTER TABLE app.role_templates ENABLE ROW LEVEL SECURITY;
|
|
100
|
+
GRANT SELECT ON app.role_templates TO authenticated;
|
|
101
|
+
DROP POLICY IF EXISTS role_templates_select ON app.role_templates;
|
|
102
|
+
CREATE POLICY role_templates_select ON app.role_templates FOR SELECT TO authenticated USING (true);
|
|
103
|
+
|
|
104
|
+
CREATE OR REPLACE FUNCTION app.seed_role_template_rows(p_template text, p_role_key text, p_role_name text, p_sort integer, p_perms text[])
|
|
105
|
+
RETURNS void LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
|
|
106
|
+
INSERT INTO app.role_templates (template, role_key, role_name, sort_order, permission)
|
|
107
|
+
SELECT p_template, p_role_key, p_role_name, p_sort, unnest(p_perms)
|
|
108
|
+
ON CONFLICT DO NOTHING;
|
|
109
|
+
$$;
|
|
110
|
+
REVOKE ALL ON FUNCTION app.seed_role_template_rows(text, text, text, integer, text[]) FROM public, anon, authenticated;
|
|
111
|
+
|
|
112
|
+
-- generic — the SDK's historical five (owner/admin/manager/staff/viewer), the
|
|
113
|
+
-- target of the tenant_members role mapping in #134.
|
|
114
|
+
SELECT app.seed_role_template_rows('generic', 'admin', 'Administrator', 1, ARRAY[
|
|
115
|
+
'tenant.read','units.read','units.manage','members.read','members.manage','roles.read','roles.manage','settings.read','settings.manage','audit.read','reports.read',
|
|
116
|
+
'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
|
|
117
|
+
SELECT app.seed_role_template_rows('generic', 'manager', 'Manager', 2, ARRAY[
|
|
118
|
+
'tenant.read','units.read','members.read','roles.read','settings.read','reports.read',
|
|
119
|
+
'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
|
|
120
|
+
SELECT app.seed_role_template_rows('generic', 'staff', 'Staff', 3, ARRAY[
|
|
121
|
+
'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit','inventory.read']);
|
|
122
|
+
SELECT app.seed_role_template_rows('generic', 'viewer', 'Viewer', 4, ARRAY[
|
|
123
|
+
'tenant.read','units.read','people.read','catalog.read','orders.read','agenda.read','inventory.read','financial.read']);
|
|
124
|
+
|
|
125
|
+
-- salon (owner_id off in the vertical template)
|
|
126
|
+
SELECT app.seed_role_template_rows('salon', 'manager', 'Gerente', 1, ARRAY[
|
|
127
|
+
'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
|
|
128
|
+
'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
|
|
129
|
+
SELECT app.seed_role_template_rows('salon', 'reception', 'Recepção', 2, ARRAY[
|
|
130
|
+
'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit']);
|
|
131
|
+
SELECT app.seed_role_template_rows('salon', 'professional', 'Profissional', 3, ARRAY[
|
|
132
|
+
'tenant.read','units.read','people.read','catalog.read','orders.read','agenda.read','agenda.edit']);
|
|
133
|
+
SELECT app.seed_role_template_rows('salon', 'financial', 'Financeiro', 4, ARRAY[
|
|
134
|
+
'tenant.read','units.read','people.read','orders.read','reports.read','financial.manage']);
|
|
135
|
+
|
|
136
|
+
-- clinic (owner_id on: professionals see their own patients; manager read_all)
|
|
137
|
+
SELECT app.seed_role_template_rows('clinic', 'manager', 'Coordenação', 1, ARRAY[
|
|
138
|
+
'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
|
|
139
|
+
'people.manage','catalog.manage','orders.manage','agenda.manage','financial.manage','inventory.manage']);
|
|
140
|
+
SELECT app.seed_role_template_rows('clinic', 'reception', 'Recepção', 2, ARRAY[
|
|
141
|
+
'tenant.read','units.read','people.read','people.create','people.edit','catalog.read','orders.read','orders.create','orders.edit','agenda.read','agenda.create','agenda.edit']);
|
|
142
|
+
SELECT app.seed_role_template_rows('clinic', 'professional', 'Profissional', 3, ARRAY[
|
|
143
|
+
'tenant.read','units.read','people.read','people.edit','catalog.read','orders.read','agenda.read','agenda.edit']);
|
|
144
|
+
SELECT app.seed_role_template_rows('clinic', 'financial', 'Financeiro', 4, ARRAY[
|
|
145
|
+
'tenant.read','units.read','people.read','orders.read','reports.read','financial.manage']);
|
|
146
|
+
|
|
147
|
+
-- restaurant
|
|
148
|
+
SELECT app.seed_role_template_rows('restaurant', 'manager', 'Gerente', 1, ARRAY[
|
|
149
|
+
'tenant.read','units.read','members.read','members.manage','roles.read','settings.read','reports.read','audit.read',
|
|
150
|
+
'people.manage','catalog.manage','orders.manage','financial.manage','inventory.manage']);
|
|
151
|
+
SELECT app.seed_role_template_rows('restaurant', 'cashier', 'Caixa', 2, ARRAY[
|
|
152
|
+
'tenant.read','units.read','people.read','catalog.read','orders.read','orders.edit','financial.read','financial.create','financial.cash_session']);
|
|
153
|
+
SELECT app.seed_role_template_rows('restaurant', 'waiter', 'Garçom', 3, ARRAY[
|
|
154
|
+
'tenant.read','units.read','people.read','people.create','catalog.read','orders.read','orders.create','orders.edit']);
|
|
155
|
+
SELECT app.seed_role_template_rows('restaurant', 'kitchen', 'Cozinha', 4, ARRAY[
|
|
156
|
+
'tenant.read','units.read','catalog.read','orders.read','orders.edit','inventory.read']);
|
|
157
|
+
|
|
158
|
+
-- ── seed_role_template(tenant, template) ────────────────────────────────
|
|
159
|
+
-- Instantiates the template's system roles for a tenant (owner + the rows
|
|
160
|
+
-- above). Idempotent: existing roles are kept, missing permissions added,
|
|
161
|
+
-- nothing removed (a tenant may have tightened a system role on purpose).
|
|
162
|
+
-- Returns the tenant's owner role id.
|
|
163
|
+
CREATE OR REPLACE FUNCTION app.seed_role_template(p_tenant uuid, p_template text DEFAULT 'generic')
|
|
164
|
+
RETURNS uuid
|
|
165
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
166
|
+
AS $$
|
|
167
|
+
DECLARE v_owner uuid;
|
|
168
|
+
BEGIN
|
|
169
|
+
IF NOT EXISTS (SELECT 1 FROM app.role_templates WHERE template = p_template) THEN
|
|
170
|
+
RAISE EXCEPTION 'seed_role_template: unknown template %', p_template;
|
|
171
|
+
END IF;
|
|
172
|
+
|
|
173
|
+
INSERT INTO app.roles (tenant_id, key, name, is_system)
|
|
174
|
+
VALUES (p_tenant, 'owner', 'Owner', true)
|
|
175
|
+
ON CONFLICT (tenant_id, key) DO NOTHING;
|
|
176
|
+
SELECT id INTO v_owner FROM app.roles WHERE tenant_id = p_tenant AND key = 'owner';
|
|
177
|
+
|
|
178
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
179
|
+
SELECT p_tenant, v_owner, p.key FROM app.permissions p
|
|
180
|
+
ON CONFLICT DO NOTHING;
|
|
181
|
+
|
|
182
|
+
INSERT INTO app.roles (tenant_id, key, name, is_system)
|
|
183
|
+
SELECT DISTINCT ON (t.role_key) p_tenant, t.role_key, t.role_name, true
|
|
184
|
+
FROM app.role_templates t WHERE t.template = p_template
|
|
185
|
+
ORDER BY t.role_key
|
|
186
|
+
ON CONFLICT (tenant_id, key) DO NOTHING;
|
|
187
|
+
|
|
188
|
+
INSERT INTO app.role_permissions (tenant_id, role_id, permission)
|
|
189
|
+
SELECT p_tenant, r.id, t.permission
|
|
190
|
+
FROM app.role_templates t
|
|
191
|
+
JOIN app.roles r ON r.tenant_id = p_tenant AND r.key = t.role_key
|
|
192
|
+
WHERE t.template = p_template
|
|
193
|
+
ON CONFLICT DO NOTHING;
|
|
194
|
+
|
|
195
|
+
RETURN v_owner;
|
|
196
|
+
END $$;
|
|
197
|
+
REVOKE ALL ON FUNCTION app.seed_role_template(uuid, text) FROM public, anon, authenticated;
|
|
198
|
+
GRANT EXECUTE ON FUNCTION app.seed_role_template(uuid, text) TO service_role;
|
|
199
|
+
|
|
200
|
+
-- ═════════════════════ business-table scaffold (§4 + §7) ═════════════════
|
|
201
|
+
-- app.scaffold_table(table, resource_type, plugin, owner_scoping, mode)
|
|
202
|
+
--
|
|
203
|
+
-- Stamps a table with the three structural columns, unique(tenant_id, id),
|
|
204
|
+
-- the composite FKs, tenant-first indexes, enable+force RLS, the updated_at
|
|
205
|
+
-- trigger and the ONE policy template every business table gets:
|
|
206
|
+
--
|
|
207
|
+
-- select tenant = current ∧ (unit null ∨ has_unit) ∧ (owner null ∨ owner = me ∨
|
|
208
|
+
-- shared_with grant ∨ {plugin}.read_all) ∧ {plugin}.read
|
|
209
|
+
-- insert tenant/unit as above ∧ {plugin}.create
|
|
210
|
+
-- update select-scope ∧ {plugin}.edit (using + with check)
|
|
211
|
+
-- delete select-scope ∧ {plugin}.delete
|
|
212
|
+
--
|
|
213
|
+
-- Vertical differences are configuration: the policy text is identical whether
|
|
214
|
+
-- the vertical fills owner_id (clinic) or leaves it null (salon); owner_scoping
|
|
215
|
+
-- is recorded so a template can say which. mode='enforce' replaces every
|
|
216
|
+
-- pre-existing policy on the table with the template; mode='shadow' keeps them
|
|
217
|
+
-- and adds the template ones (permissive policies OR together, so shadow adds no
|
|
218
|
+
-- restriction) — app.shadow_report (#135) then compares old vs new decisions.
|
|
219
|
+
CREATE TABLE IF NOT EXISTS app.scaffold_registry (
|
|
220
|
+
table_schema text NOT NULL,
|
|
221
|
+
table_name text NOT NULL,
|
|
222
|
+
resource_type text NOT NULL,
|
|
223
|
+
plugin text NOT NULL,
|
|
224
|
+
owner_scoping boolean NOT NULL DEFAULT true,
|
|
225
|
+
mode text NOT NULL CHECK (mode IN ('shadow', 'enforce')),
|
|
226
|
+
previous_policies text[] NOT NULL DEFAULT '{}',
|
|
227
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
228
|
+
PRIMARY KEY (table_schema, table_name)
|
|
229
|
+
);
|
|
230
|
+
ALTER TABLE app.scaffold_registry ENABLE ROW LEVEL SECURITY;
|
|
231
|
+
GRANT SELECT ON app.scaffold_registry TO authenticated;
|
|
232
|
+
DROP POLICY IF EXISTS scaffold_registry_select ON app.scaffold_registry;
|
|
233
|
+
CREATE POLICY scaffold_registry_select ON app.scaffold_registry FOR SELECT TO authenticated USING (true);
|
|
234
|
+
|
|
235
|
+
CREATE OR REPLACE FUNCTION app.scaffold_table(
|
|
236
|
+
p_table regclass,
|
|
237
|
+
p_resource_type text,
|
|
238
|
+
p_plugin text,
|
|
239
|
+
p_owner_scoping boolean DEFAULT true,
|
|
240
|
+
p_mode text DEFAULT 'enforce'
|
|
241
|
+
)
|
|
242
|
+
RETURNS void
|
|
243
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
244
|
+
AS $$
|
|
245
|
+
DECLARE
|
|
246
|
+
v_schema text;
|
|
247
|
+
v_name text;
|
|
248
|
+
v_q text; -- quoted qualified name
|
|
249
|
+
v_prev text[] := '{}';
|
|
250
|
+
v_rows bigint;
|
|
251
|
+
r record;
|
|
252
|
+
v_scope text; -- the row-scope predicate shared by every policy
|
|
253
|
+
BEGIN
|
|
254
|
+
IF p_mode NOT IN ('shadow', 'enforce') THEN
|
|
255
|
+
RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
|
|
256
|
+
END IF;
|
|
257
|
+
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
258
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
259
|
+
v_q := format('%I.%I', v_schema, v_name);
|
|
260
|
+
|
|
261
|
+
-- ── columns
|
|
262
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
|
|
263
|
+
RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
|
|
264
|
+
END IF;
|
|
265
|
+
EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
|
|
266
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'tenant_id') THEN
|
|
267
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
|
|
268
|
+
IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
|
|
269
|
+
END IF;
|
|
270
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
|
|
271
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
|
|
272
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
273
|
+
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
274
|
+
|
|
275
|
+
-- ── unique(tenant_id, id) — enables composite FKs from other tables
|
|
276
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
|
|
277
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
|
|
278
|
+
END IF;
|
|
279
|
+
-- ── composite FKs: unit within tenant, owner a member of the tenant
|
|
280
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
|
|
281
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL', v_q, v_name || '_unit_fk');
|
|
282
|
+
END IF;
|
|
283
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
|
|
284
|
+
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL', v_q, v_name || '_owner_fk');
|
|
285
|
+
END IF;
|
|
286
|
+
|
|
287
|
+
-- ── tenant-first indexes
|
|
288
|
+
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
|
|
289
|
+
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, owner_id) WHERE owner_id IS NOT NULL', v_name || '_tenant_owner_idx', v_q);
|
|
290
|
+
|
|
291
|
+
-- ── updated_at
|
|
292
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
|
|
293
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
|
|
294
|
+
|
|
295
|
+
-- ── RLS
|
|
296
|
+
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
|
|
297
|
+
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
|
|
298
|
+
IF p_mode = 'enforce' THEN
|
|
299
|
+
FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
|
|
300
|
+
AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
|
|
301
|
+
v_prev := v_prev || r.policyname;
|
|
302
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
|
|
303
|
+
END LOOP;
|
|
304
|
+
END IF;
|
|
305
|
+
|
|
306
|
+
v_scope := format(
|
|
307
|
+
'tenant_id = (select app.current_tenant_id())'
|
|
308
|
+
|| ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))'
|
|
309
|
+
|| ' AND (owner_id IS NULL OR owner_id = (select auth.uid())'
|
|
310
|
+
|| ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
|
|
311
|
+
|| ' OR (select app.has_permission(%L, unit_id)))',
|
|
312
|
+
p_resource_type, p_plugin || '.read_all');
|
|
313
|
+
|
|
314
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
315
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
316
|
+
v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
|
|
317
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
|
|
318
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR INSERT TO authenticated WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
|
|
319
|
+
v_name || '_authz_insert', v_q, p_plugin || '.create');
|
|
320
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
321
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR UPDATE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id))) WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (unit_id IS NULL OR (select app.has_unit(unit_id))) AND (select app.has_permission(%L, unit_id)))',
|
|
322
|
+
v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
|
|
323
|
+
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
324
|
+
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
325
|
+
v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
|
|
326
|
+
|
|
327
|
+
INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
|
|
328
|
+
VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
|
|
329
|
+
ON CONFLICT (table_schema, table_name) DO UPDATE
|
|
330
|
+
SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
|
|
331
|
+
mode = EXCLUDED.mode, applied_at = now(),
|
|
332
|
+
previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
|
|
333
|
+
THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
|
|
334
|
+
ELSE EXCLUDED.previous_policies END;
|
|
335
|
+
END $$;
|
|
336
|
+
REVOKE ALL ON FUNCTION app.scaffold_table(regclass, text, text, boolean, text) FROM public, anon, authenticated;
|
|
337
|
+
|
|
338
|
+
-- ═════════════════════ read views for the UI (public.v_authz_*) ═══════════
|
|
339
|
+
-- security_invoker: the caller's RLS on the app tables decides what she sees.
|
|
340
|
+
CREATE OR REPLACE VIEW public.v_authz_units WITH (security_invoker = true) AS
|
|
341
|
+
SELECT tenant_id, id, kind, parent_unit_id, name, slug, active, company_legal_entity_id, metadata, created_at, updated_at FROM app.units;
|
|
342
|
+
CREATE OR REPLACE VIEW public.v_authz_roles WITH (security_invoker = true) AS
|
|
343
|
+
SELECT tenant_id, id, key, name, description, is_system, created_at, updated_at FROM app.roles;
|
|
344
|
+
CREATE OR REPLACE VIEW public.v_authz_permissions WITH (security_invoker = true) AS
|
|
345
|
+
SELECT key, plugin, description FROM app.permissions;
|
|
346
|
+
CREATE OR REPLACE VIEW public.v_authz_role_permissions WITH (security_invoker = true) AS
|
|
347
|
+
SELECT tenant_id, role_id, permission FROM app.role_permissions;
|
|
348
|
+
CREATE OR REPLACE VIEW public.v_authz_memberships WITH (security_invoker = true) AS
|
|
349
|
+
SELECT tenant_id, user_id, id, role_id, all_units, active, person_id, invited_by, created_at, updated_at FROM app.memberships;
|
|
350
|
+
CREATE OR REPLACE VIEW public.v_authz_membership_units WITH (security_invoker = true) AS
|
|
351
|
+
SELECT tenant_id, user_id, unit_id, role_override_id, created_at FROM app.membership_units;
|
|
352
|
+
CREATE OR REPLACE VIEW public.v_authz_user_unit_access WITH (security_invoker = true) AS
|
|
353
|
+
SELECT tenant_id, user_id, unit_id, via FROM app.user_unit_access;
|
|
354
|
+
CREATE OR REPLACE VIEW public.v_authz_resource_grants WITH (security_invoker = true) AS
|
|
355
|
+
SELECT tenant_id, resource_type, resource_id, relation, subject_type, subject_id, created_by, created_at FROM app.resource_grants;
|
|
356
|
+
CREATE OR REPLACE VIEW public.v_authz_role_templates WITH (security_invoker = true) AS
|
|
357
|
+
SELECT template, role_key, role_name, sort_order, permission FROM app.role_templates;
|
|
358
|
+
-- Reconciliation runs whose diff was not zero outside a migration: the alert.
|
|
359
|
+
CREATE OR REPLACE VIEW public.v_authz_reconciliation_alerts WITH (security_invoker = true) AS
|
|
360
|
+
SELECT id, tenant_id, run_at, source, added, removed, details
|
|
361
|
+
FROM app.authz_reconciliation_log
|
|
362
|
+
WHERE source <> 'migration' AND (added <> 0 OR removed <> 0);
|
|
363
|
+
GRANT SELECT ON public.v_authz_units, public.v_authz_roles, public.v_authz_permissions, public.v_authz_role_permissions,
|
|
364
|
+
public.v_authz_memberships, public.v_authz_membership_units, public.v_authz_user_unit_access,
|
|
365
|
+
public.v_authz_resource_grants, public.v_authz_role_templates, public.v_authz_reconciliation_alerts
|
|
366
|
+
TO authenticated, service_role;
|
|
367
|
+
-- The simple views over one table are auto-updatable: writes through them hit
|
|
368
|
+
-- the write policies of 041 (units/roles/role_permissions/memberships/units/grants).
|
|
369
|
+
GRANT INSERT, UPDATE, DELETE ON public.v_authz_units, public.v_authz_roles, public.v_authz_role_permissions,
|
|
370
|
+
public.v_authz_memberships, public.v_authz_membership_units, public.v_authz_resource_grants
|
|
371
|
+
TO authenticated, service_role;
|
|
372
|
+
|
|
373
|
+
-- ── the session's authz context, once per session per unit (§8) ─────────
|
|
374
|
+
CREATE OR REPLACE FUNCTION public.authz_my_permissions(p_unit uuid DEFAULT NULL)
|
|
375
|
+
RETURNS SETOF text
|
|
376
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
377
|
+
AS $$
|
|
378
|
+
SELECT rp.permission
|
|
379
|
+
FROM app.role_permissions rp
|
|
380
|
+
WHERE rp.tenant_id = app.current_tenant_id()
|
|
381
|
+
AND rp.role_id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)
|
|
382
|
+
ORDER BY 1;
|
|
383
|
+
$$;
|
|
384
|
+
CREATE OR REPLACE FUNCTION public.authz_context()
|
|
385
|
+
RETURNS jsonb
|
|
386
|
+
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
|
|
387
|
+
AS $$
|
|
388
|
+
SELECT jsonb_build_object(
|
|
389
|
+
'tenant_id', app.current_tenant_id(),
|
|
390
|
+
'user_id', auth.uid(),
|
|
391
|
+
'role_key', (SELECT r.key FROM app.memberships m JOIN app.roles r ON r.tenant_id = m.tenant_id AND r.id = m.role_id
|
|
392
|
+
WHERE m.tenant_id = app.current_tenant_id() AND m.user_id = auth.uid() AND m.active),
|
|
393
|
+
'all_units', (SELECT m.all_units FROM app.memberships m WHERE m.tenant_id = app.current_tenant_id() AND m.user_id = auth.uid() AND m.active),
|
|
394
|
+
'units', coalesce((SELECT jsonb_agg(jsonb_build_object('unit_id', a.unit_id, 'via', a.via) ORDER BY a.unit_id)
|
|
395
|
+
FROM app.user_unit_access a WHERE a.tenant_id = app.current_tenant_id() AND a.user_id = auth.uid()), '[]'::jsonb),
|
|
396
|
+
'permissions', coalesce((SELECT jsonb_agg(p ORDER BY p) FROM public.authz_my_permissions(NULL) p), '[]'::jsonb)
|
|
397
|
+
);
|
|
398
|
+
$$;
|
|
399
|
+
REVOKE ALL ON FUNCTION public.authz_my_permissions(uuid) FROM public, anon;
|
|
400
|
+
REVOKE ALL ON FUNCTION public.authz_context() FROM public, anon;
|
|
401
|
+
GRANT EXECUTE ON FUNCTION public.authz_my_permissions(uuid) TO authenticated, service_role;
|
|
402
|
+
GRANT EXECUTE ON FUNCTION public.authz_context() TO authenticated, service_role;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 060_tenancy_seam.sql — the tenancy seam moves onto the authz core (#134).
|
|
3
|
+
--
|
|
4
|
+
-- Three functions the whole fleet's RLS resolves through are re-authored to read
|
|
5
|
+
-- app.memberships instead of the legacy public.tenant_members:
|
|
6
|
+
--
|
|
7
|
+
-- public.user_tenant_ids() the seam of ~260 policies (028): same signature,
|
|
8
|
+
-- same claim semantics, new source table.
|
|
9
|
+
-- public.is_tenant_admin(uuid) the legacy "owner|admin" bypass. It is now a
|
|
10
|
+
-- compat shim over permissions: TRUE only when the
|
|
11
|
+
-- caller's active membership holds `tenant.manage`.
|
|
12
|
+
-- No role literal survives in its body. Deprecated:
|
|
13
|
+
-- policies of 001/003/021 still call it; new code
|
|
14
|
+
-- uses app.has_permission(...).
|
|
15
|
+
-- public.shares_tenant_with(uuid) (023) profiles visibility, same rule on app.*.
|
|
16
|
+
--
|
|
17
|
+
-- public.tenants gains `role_template` (which vertical template seeded its system
|
|
18
|
+
-- roles — needed to re-seed a tenant when a template grows keys) and loses the
|
|
19
|
+
-- open `tenants_insert` policy: a tenant is created by public.create_tenant (051),
|
|
20
|
+
-- never by a bare INSERT.
|
|
21
|
+
--
|
|
22
|
+
-- Idempotent and replay-safe: CREATE OR REPLACE keeps the functions' oids,
|
|
23
|
+
-- ownership and grants (every existing policy keeps compiling); the policy is
|
|
24
|
+
-- DROP IF EXISTS + CREATE. On a force replay 001/002/006/028 re-create the old
|
|
25
|
+
-- bodies first and this file re-authors them again — the chain converges.
|
|
26
|
+
-- ============================================================================
|
|
27
|
+
|
|
28
|
+
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS role_template text;
|
|
29
|
+
-- The synthetic seed records its template in settings; real tenants created by
|
|
30
|
+
-- create_tenant record it in the column. Backfill the one from the other.
|
|
31
|
+
UPDATE public.tenants
|
|
32
|
+
SET role_template = settings ->> 'template'
|
|
33
|
+
WHERE role_template IS NULL
|
|
34
|
+
AND settings ->> 'template' IS NOT NULL
|
|
35
|
+
AND EXISTS (SELECT 1 FROM app.role_templates rt WHERE rt.template = public.tenants.settings ->> 'template');
|
|
36
|
+
|
|
37
|
+
-- ── public.user_tenant_ids() ────────────────────────────────────────────
|
|
38
|
+
-- Structure copied from 028 (read its header for the reasoning): a top-level
|
|
39
|
+
-- `tenant_id` claim NARROWS and never widens; a signed-in session is intersected
|
|
40
|
+
-- with real (active) membership; a session-less signed token is scoped by the
|
|
41
|
+
-- claim alone; a present-but-unparseable claim resolves to nothing; no claim ⇒
|
|
42
|
+
-- every active membership. Only the source table changes.
|
|
43
|
+
CREATE OR REPLACE FUNCTION public.user_tenant_ids()
|
|
44
|
+
RETURNS SETOF uuid
|
|
45
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
46
|
+
SET search_path = ''
|
|
47
|
+
AS $$
|
|
48
|
+
WITH claim AS (
|
|
49
|
+
SELECT coalesce(jsonb_exists(auth.jwt(), 'tenant_id'), false) AS present,
|
|
50
|
+
auth.jwt() ->> 'tenant_id' AS raw
|
|
51
|
+
),
|
|
52
|
+
scoped AS (
|
|
53
|
+
SELECT raw::uuid AS tenant_id
|
|
54
|
+
FROM claim
|
|
55
|
+
WHERE raw ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
56
|
+
)
|
|
57
|
+
SELECT m.tenant_id
|
|
58
|
+
FROM app.memberships m
|
|
59
|
+
WHERE m.user_id = auth.uid()
|
|
60
|
+
AND m.active
|
|
61
|
+
AND NOT (SELECT present FROM claim)
|
|
62
|
+
UNION
|
|
63
|
+
SELECT s.tenant_id
|
|
64
|
+
FROM scoped s
|
|
65
|
+
WHERE auth.uid() IS NULL
|
|
66
|
+
OR EXISTS (
|
|
67
|
+
SELECT 1 FROM app.memberships m
|
|
68
|
+
WHERE m.user_id = auth.uid() AND m.tenant_id = s.tenant_id AND m.active
|
|
69
|
+
);
|
|
70
|
+
$$;
|
|
71
|
+
-- Same grant set as 028 (PUBLIC keeps its default EXECUTE on purpose: RLS
|
|
72
|
+
-- expressions run as the querying role and anon storefront reads must resolve
|
|
73
|
+
-- to zero rows, not to "permission denied for function").
|
|
74
|
+
GRANT EXECUTE ON FUNCTION public.user_tenant_ids() TO authenticated, service_role;
|
|
75
|
+
DO $$
|
|
76
|
+
BEGIN
|
|
77
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_connector') THEN
|
|
78
|
+
EXECUTE 'GRANT EXECUTE ON FUNCTION public.user_tenant_ids() TO fayz_connector';
|
|
79
|
+
END IF;
|
|
80
|
+
END $$;
|
|
81
|
+
|
|
82
|
+
-- ── public.is_tenant_admin(uuid) — compat shim over permissions ─────────
|
|
83
|
+
-- No role literal: the answer is "does the caller's active membership in that
|
|
84
|
+
-- tenant hold tenant.manage" (the base role; unit overrides do not apply to a
|
|
85
|
+
-- tenant-level question). Callers pass the tenant, so it is used verbatim
|
|
86
|
+
-- rather than app.current_tenant_id().
|
|
87
|
+
CREATE OR REPLACE FUNCTION public.is_tenant_admin(p_tenant_id uuid)
|
|
88
|
+
RETURNS boolean
|
|
89
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
90
|
+
SET search_path = ''
|
|
91
|
+
AS $$
|
|
92
|
+
SELECT EXISTS (
|
|
93
|
+
SELECT 1
|
|
94
|
+
FROM app.memberships m
|
|
95
|
+
JOIN app.role_permissions rp
|
|
96
|
+
ON rp.tenant_id = m.tenant_id AND rp.role_id = m.role_id
|
|
97
|
+
WHERE m.tenant_id = p_tenant_id
|
|
98
|
+
AND m.user_id = auth.uid()
|
|
99
|
+
AND m.active
|
|
100
|
+
AND rp.permission = 'tenant.manage'
|
|
101
|
+
);
|
|
102
|
+
$$;
|
|
103
|
+
COMMENT ON FUNCTION public.is_tenant_admin(uuid) IS
|
|
104
|
+
'DEPRECATED (#134): compat shim kept for the policies of 001/003/021. TRUE iff the caller''s active membership holds tenant.manage. New code uses app.has_permission(...). Removal: Phase 1, once no policy references it.';
|
|
105
|
+
REVOKE ALL ON FUNCTION public.is_tenant_admin(uuid) FROM public, anon;
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.is_tenant_admin(uuid) TO authenticated, service_role;
|
|
107
|
+
|
|
108
|
+
-- ── public.shares_tenant_with(uuid) ─────────────────────────────────────
|
|
109
|
+
CREATE OR REPLACE FUNCTION public.shares_tenant_with(p_user_id uuid)
|
|
110
|
+
RETURNS boolean
|
|
111
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
112
|
+
SET search_path = ''
|
|
113
|
+
AS $$
|
|
114
|
+
SELECT EXISTS (
|
|
115
|
+
SELECT 1
|
|
116
|
+
FROM app.memberships mine
|
|
117
|
+
JOIN app.memberships theirs ON theirs.tenant_id = mine.tenant_id
|
|
118
|
+
WHERE mine.user_id = auth.uid() AND mine.active
|
|
119
|
+
AND theirs.user_id = p_user_id AND theirs.active
|
|
120
|
+
);
|
|
121
|
+
$$;
|
|
122
|
+
REVOKE ALL ON FUNCTION public.shares_tenant_with(uuid) FROM public, anon;
|
|
123
|
+
GRANT EXECUTE ON FUNCTION public.shares_tenant_with(uuid) TO authenticated, service_role;
|
|
124
|
+
|
|
125
|
+
-- ── public.tenants policies ─────────────────────────────────────────────
|
|
126
|
+
-- Read: through the seam (no detour via the legacy table). Insert: none — a
|
|
127
|
+
-- bare INSERT would create a tenant with no owner membership; create_tenant
|
|
128
|
+
-- (051) is the only path. Update keeps is_tenant_admin (now permission-based).
|
|
129
|
+
DROP POLICY IF EXISTS "tenants_select" ON public.tenants;
|
|
130
|
+
CREATE POLICY "tenants_select" ON public.tenants FOR SELECT TO authenticated
|
|
131
|
+
USING (id IN (SELECT public.user_tenant_ids()));
|
|
132
|
+
DROP POLICY IF EXISTS "tenants_insert" ON public.tenants;
|