@fayz-ai/db 0.11.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.
Files changed (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,230 @@
1
+ -- ============================================================================
2
+ -- 050_authz_core.sql — the authz core (ADR 0006 / PRD 01 / #133): schema §3.
3
+ --
4
+ -- Tenancy, RBAC catalog, membership, the precomputed unit scope and the generic
5
+ -- relation tuples of the Multi-Tenant Authorization & Data Scoping
6
+ -- Architecture v2. Exactly two authorization surfaces (user_unit_access,
7
+ -- resource_grants) plus the RBAC catalog; business tables carry tenant_id /
8
+ -- unit_id / owner_id and one RLS template (043) — nothing else decides access.
9
+ --
10
+ -- Where it lives (DECISIONS 2026-08-18): schema `app`, per the spec and the
11
+ -- issue's default until the platform session rules otherwise. `public.tenants`
12
+ -- stays the tenant table — every FK in the fleet already points at it — and
13
+ -- gains `kind`. Reads for UI go through public.v_authz_* (043); writes through
14
+ -- the RLS write policies below or the RPCs of #134.
15
+ --
16
+ -- Idempotent and replay-safe: IF NOT EXISTS / guarded DO blocks throughout;
17
+ -- policies are DROP IF EXISTS + CREATE. Nothing here touches tenant_members
18
+ -- or locations — retiring them is #134's migration, on top of this.
19
+ -- ============================================================================
20
+
21
+ CREATE SCHEMA IF NOT EXISTS app;
22
+ GRANT USAGE ON SCHEMA app TO authenticated, service_role;
23
+ -- anon deliberately gets nothing: the authz core has no anonymous surface.
24
+
25
+ -- ─────────────────────────────── Tenancy ───────────────────────────────
26
+ -- tenants(kind network|business): a network groups units under franchisee /
27
+ -- regional parent units; a business is one company. ADR 0002 maps V1's licence
28
+ -- to a tenant and V1's franchise mode to kind='network'.
29
+ ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'business';
30
+ DO $$
31
+ BEGIN
32
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenants_kind_check' AND conrelid = 'public.tenants'::regclass) THEN
33
+ ALTER TABLE public.tenants ADD CONSTRAINT tenants_kind_check CHECK (kind IN ('network', 'business'));
34
+ END IF;
35
+ END $$;
36
+
37
+ CREATE TABLE IF NOT EXISTS app.units (
38
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
39
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
40
+ kind text NOT NULL CHECK (kind IN ('hq', 'branch')),
41
+ parent_unit_id uuid, -- grouping: franchisee, region (subtree propagation)
42
+ name text NOT NULL,
43
+ slug text,
44
+ active boolean NOT NULL DEFAULT true,
45
+ company_legal_entity_id uuid, -- Empresa: public.company_legal_entities (CNPJ/CPF); FK added by 065 (#138)
46
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
47
+ created_at timestamptz NOT NULL DEFAULT now(),
48
+ updated_at timestamptz NOT NULL DEFAULT now(),
49
+ PRIMARY KEY (id),
50
+ UNIQUE (tenant_id, id),
51
+ UNIQUE (tenant_id, slug),
52
+ FOREIGN KEY (tenant_id, parent_unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL,
53
+ CHECK (parent_unit_id IS DISTINCT FROM id)
54
+ );
55
+ CREATE INDEX IF NOT EXISTS units_tenant_parent_idx ON app.units (tenant_id, parent_unit_id);
56
+ CREATE UNIQUE INDEX IF NOT EXISTS units_one_hq_per_tenant ON app.units (tenant_id) WHERE kind = 'hq';
57
+ DROP TRIGGER IF EXISTS units_updated_at ON app.units;
58
+ CREATE TRIGGER units_updated_at BEFORE UPDATE ON app.units
59
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
60
+
61
+ -- ─────────────────────────────── RBAC catalog ──────────────────────────
62
+ -- Global catalog: keys are '{plugin}.{action}', seeded by the platform and by
63
+ -- each plugin's declaredFeatures (#136). A key is a row, never DDL.
64
+ CREATE TABLE IF NOT EXISTS app.permissions (
65
+ key text PRIMARY KEY,
66
+ plugin text NOT NULL,
67
+ description text,
68
+ created_at timestamptz NOT NULL DEFAULT now(),
69
+ CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_-]*)+$')
70
+ );
71
+ CREATE INDEX IF NOT EXISTS permissions_plugin_idx ON app.permissions (plugin);
72
+
73
+ -- Roles per tenant. is_system rows come from the vertical template (owner,
74
+ -- manager, reception, professional, …) and cannot be deleted; a tenant may add
75
+ -- its own ("kit por cargo" of a network is a tenant role, not a system one).
76
+ CREATE TABLE IF NOT EXISTS app.roles (
77
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
78
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
79
+ key text NOT NULL,
80
+ name text NOT NULL,
81
+ description text,
82
+ is_system boolean NOT NULL DEFAULT false,
83
+ created_at timestamptz NOT NULL DEFAULT now(),
84
+ updated_at timestamptz NOT NULL DEFAULT now(),
85
+ PRIMARY KEY (tenant_id, id),
86
+ UNIQUE (id),
87
+ UNIQUE (tenant_id, key),
88
+ CHECK (key ~ '^[a-z][a-z0-9_-]*$')
89
+ );
90
+ DROP TRIGGER IF EXISTS roles_updated_at ON app.roles;
91
+ CREATE TRIGGER roles_updated_at BEFORE UPDATE ON app.roles
92
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
93
+
94
+ CREATE TABLE IF NOT EXISTS app.role_permissions (
95
+ tenant_id uuid NOT NULL,
96
+ role_id uuid NOT NULL,
97
+ permission text NOT NULL REFERENCES app.permissions(key) ON DELETE CASCADE,
98
+ created_at timestamptz NOT NULL DEFAULT now(),
99
+ PRIMARY KEY (tenant_id, role_id, permission),
100
+ FOREIGN KEY (tenant_id, role_id) REFERENCES app.roles(tenant_id, id) ON DELETE CASCADE
101
+ );
102
+ CREATE INDEX IF NOT EXISTS role_permissions_perm_idx ON app.role_permissions (tenant_id, permission);
103
+
104
+ -- ─────────────────────────────── Membership ────────────────────────────
105
+ -- One membership per (tenant, user): the base role, whether the user sees every
106
+ -- unit (franchisor / network owner), and whether it is active. person_id links
107
+ -- the login to the one Person of that human in the tenant (#137).
108
+ CREATE TABLE IF NOT EXISTS app.memberships (
109
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
110
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
111
+ id uuid NOT NULL DEFAULT gen_random_uuid(),
112
+ role_id uuid NOT NULL,
113
+ all_units boolean NOT NULL DEFAULT false,
114
+ active boolean NOT NULL DEFAULT true,
115
+ person_id uuid,
116
+ invited_by uuid,
117
+ created_at timestamptz NOT NULL DEFAULT now(),
118
+ updated_at timestamptz NOT NULL DEFAULT now(),
119
+ PRIMARY KEY (tenant_id, user_id),
120
+ UNIQUE (id),
121
+ FOREIGN KEY (tenant_id, role_id) REFERENCES app.roles(tenant_id, id)
122
+ );
123
+ CREATE INDEX IF NOT EXISTS memberships_user_idx ON app.memberships (user_id) WHERE active;
124
+ CREATE INDEX IF NOT EXISTS memberships_role_idx ON app.memberships (tenant_id, role_id);
125
+ DROP TRIGGER IF EXISTS memberships_updated_at ON app.memberships;
126
+ CREATE TRIGGER memberships_updated_at BEFORE UPDATE ON app.memberships
127
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
128
+
129
+ -- Explicit units of a member (Toast/Square style) with an optional per-unit
130
+ -- role override — the effective role in that unit (§8).
131
+ CREATE TABLE IF NOT EXISTS app.membership_units (
132
+ tenant_id uuid NOT NULL,
133
+ user_id uuid NOT NULL,
134
+ unit_id uuid NOT NULL,
135
+ role_override_id uuid,
136
+ created_at timestamptz NOT NULL DEFAULT now(),
137
+ PRIMARY KEY (tenant_id, user_id, unit_id),
138
+ FOREIGN KEY (tenant_id, user_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE CASCADE,
139
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE CASCADE,
140
+ FOREIGN KEY (tenant_id, role_override_id) REFERENCES app.roles(tenant_id, id)
141
+ );
142
+ CREATE INDEX IF NOT EXISTS membership_units_unit_idx ON app.membership_units (tenant_id, unit_id);
143
+
144
+ -- ─────────────── Precomputed scope (Salesforce-style share table) ───────
145
+ -- DERIVED. Maintained by the fan-out triggers (042) and the reconciliation job.
146
+ -- Never written by app code: no INSERT/UPDATE/DELETE grant to any API role.
147
+ CREATE TABLE IF NOT EXISTS app.user_unit_access (
148
+ tenant_id uuid NOT NULL,
149
+ user_id uuid NOT NULL,
150
+ unit_id uuid NOT NULL,
151
+ via text NOT NULL CHECK (via IN ('all_units', 'explicit', 'subtree')),
152
+ PRIMARY KEY (tenant_id, user_id, unit_id),
153
+ FOREIGN KEY (tenant_id, user_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE CASCADE,
154
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE CASCADE
155
+ );
156
+ CREATE INDEX IF NOT EXISTS user_unit_access_unit_idx ON app.user_unit_access (tenant_id, unit_id);
157
+
158
+ -- Diff-log of every reconciliation run (§6). diff <> 0 outside a migration is
159
+ -- an incident, not a perf issue: v_authz_reconciliation_alerts (043) lists them.
160
+ CREATE TABLE IF NOT EXISTS app.authz_reconciliation_log (
161
+ id bigserial PRIMARY KEY,
162
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
163
+ run_at timestamptz NOT NULL DEFAULT now(),
164
+ source text NOT NULL CHECK (source IN ('migration', 'scheduled', 'manual', 'test')),
165
+ added integer NOT NULL DEFAULT 0,
166
+ removed integer NOT NULL DEFAULT 0,
167
+ details jsonb NOT NULL DEFAULT '[]'::jsonb
168
+ );
169
+ CREATE INDEX IF NOT EXISTS authz_reconciliation_log_tenant_idx ON app.authz_reconciliation_log (tenant_id, run_at DESC);
170
+
171
+ -- ─────────────── Generic relation tuples (ERPNext UserPermission / Zanzibar) ───
172
+ CREATE TABLE IF NOT EXISTS app.resource_grants (
173
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
174
+ resource_type text NOT NULL, -- plugin-namespaced: 'catalog.product', 'people.person', …
175
+ resource_id uuid NOT NULL,
176
+ relation text NOT NULL, -- 'available_in', 'owner', 'shared_with', 'visible_to', …
177
+ subject_type text NOT NULL CHECK (subject_type IN ('user', 'unit', 'role')),
178
+ subject_id uuid NOT NULL,
179
+ created_by uuid,
180
+ created_at timestamptz NOT NULL DEFAULT now(),
181
+ PRIMARY KEY (tenant_id, resource_type, resource_id, relation, subject_type, subject_id),
182
+ CHECK (resource_type ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$'),
183
+ CHECK (relation ~ '^[a-z][a-z0-9_]*$')
184
+ );
185
+ -- Reverse lookup: "what can this subject access?"
186
+ CREATE INDEX IF NOT EXISTS resource_grants_subject_idx
187
+ ON app.resource_grants (tenant_id, subject_type, subject_id, relation, resource_type);
188
+
189
+ -- ─────────────────────────────── Grants ────────────────────────────────
190
+ -- Reads for every API role that has a session; RLS (below) narrows to the
191
+ -- caller's tenant. Writes only where a policy exists to gate them.
192
+ GRANT SELECT ON app.units, app.permissions, app.roles, app.role_permissions,
193
+ app.memberships, app.membership_units, app.user_unit_access,
194
+ app.resource_grants, app.authz_reconciliation_log TO authenticated;
195
+ GRANT INSERT, UPDATE, DELETE ON app.units, app.roles, app.role_permissions,
196
+ app.memberships, app.membership_units, app.resource_grants TO authenticated;
197
+ GRANT ALL ON ALL TABLES IN SCHEMA app TO service_role;
198
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO service_role;
199
+ -- user_unit_access is derived: no API role may write it (the trigger functions
200
+ -- are SECURITY DEFINER and owned by the migration role).
201
+ REVOKE INSERT, UPDATE, DELETE ON app.user_unit_access FROM authenticated, anon;
202
+ REVOKE ALL ON app.permissions FROM anon;
203
+ REVOKE INSERT, UPDATE, DELETE ON app.permissions FROM authenticated;
204
+ ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE ALL ON TABLES FROM anon;
205
+ ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE EXECUTE ON FUNCTIONS FROM public, anon;
206
+
207
+ -- ─────────────────────────────── RLS on the authz tables ────────────────
208
+ -- The helpers of 041 are needed by the policies below; 041 runs after this file,
209
+ -- so the policies are (re)created at the end of 041 where the functions exist.
210
+ ALTER TABLE app.units ENABLE ROW LEVEL SECURITY;
211
+ ALTER TABLE app.units FORCE ROW LEVEL SECURITY;
212
+ ALTER TABLE app.permissions ENABLE ROW LEVEL SECURITY;
213
+ ALTER TABLE app.roles ENABLE ROW LEVEL SECURITY;
214
+ ALTER TABLE app.roles FORCE ROW LEVEL SECURITY;
215
+ ALTER TABLE app.role_permissions ENABLE ROW LEVEL SECURITY;
216
+ ALTER TABLE app.role_permissions FORCE ROW LEVEL SECURITY;
217
+ ALTER TABLE app.memberships ENABLE ROW LEVEL SECURITY;
218
+ ALTER TABLE app.memberships FORCE ROW LEVEL SECURITY;
219
+ ALTER TABLE app.membership_units ENABLE ROW LEVEL SECURITY;
220
+ ALTER TABLE app.membership_units FORCE ROW LEVEL SECURITY;
221
+ ALTER TABLE app.user_unit_access ENABLE ROW LEVEL SECURITY;
222
+ ALTER TABLE app.user_unit_access FORCE ROW LEVEL SECURITY;
223
+ ALTER TABLE app.resource_grants ENABLE ROW LEVEL SECURITY;
224
+ ALTER TABLE app.resource_grants FORCE ROW LEVEL SECURITY;
225
+ ALTER TABLE app.authz_reconciliation_log ENABLE ROW LEVEL SECURITY;
226
+ ALTER TABLE app.authz_reconciliation_log FORCE ROW LEVEL SECURITY;
227
+
228
+ -- The catalog is global and harmless to read: every signed-in user may list it.
229
+ DROP POLICY IF EXISTS permissions_select ON app.permissions;
230
+ CREATE POLICY permissions_select ON app.permissions FOR SELECT TO authenticated USING (true);
@@ -0,0 +1,330 @@
1
+ -- ============================================================================
2
+ -- 051_authz_helpers.sql — the RLS helpers (§7) and effective role (§8).
3
+ --
4
+ -- All `STABLE SECURITY DEFINER SET search_path = ''`, meant to be called wrapped
5
+ -- in `(select …)` so the planner caches them per statement (initPlan). They
6
+ -- read the authz tables with the migration role's privileges (no recursion into
7
+ -- RLS) and never raise: an RLS policy is not a place to throw, so junk resolves
8
+ -- to "no access".
9
+ --
10
+ -- Also here: the RLS policies of the authz tables themselves (040 could not
11
+ -- create them before the helpers existed).
12
+ -- ============================================================================
13
+
14
+ -- ── current_tenant_id() ─────────────────────────────────────────────────
15
+ -- The tenant this request acts in. Same claim semantics as public.user_tenant_ids()
16
+ -- (028): a top-level `tenant_id` JWT claim NARROWS and never widens — with a
17
+ -- signed-in session it must match an active membership; a session-less signed
18
+ -- token is scoped by the claim alone (only we can mint one). Without a claim,
19
+ -- a user with exactly one active membership acts in it; a multi-tenant user
20
+ -- without a claim acts in none (fail closed — the app sets the claim / active
21
+ -- tenant). Only tenant_id (and, later, the active unit) live in the JWT:
22
+ -- permissions and unit scope always come from tables (revocation is instant).
23
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
24
+ RETURNS uuid
25
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
26
+ SET search_path = ''
27
+ AS $$
28
+ DECLARE
29
+ v_uid uuid := auth.uid();
30
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
31
+ v_raw text;
32
+ v_tenant uuid;
33
+ v_n integer;
34
+ BEGIN
35
+ IF jsonb_exists(v_jwt, 'tenant_id') THEN
36
+ v_raw := v_jwt ->> 'tenant_id';
37
+ -- Presence expresses intent to be narrowed; an unparseable value is no tenant.
38
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
39
+ RETURN NULL;
40
+ END IF;
41
+ v_tenant := v_raw::uuid;
42
+ IF v_uid IS NULL THEN
43
+ RETURN v_tenant;
44
+ END IF;
45
+ IF EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
46
+ RETURN v_tenant;
47
+ END IF;
48
+ RETURN NULL;
49
+ END IF;
50
+
51
+ IF v_uid IS NULL THEN
52
+ RETURN NULL;
53
+ END IF;
54
+ SELECT count(*), (array_agg(m.tenant_id))[1] INTO v_n, v_tenant
55
+ FROM app.memberships m WHERE m.user_id = v_uid AND m.active;
56
+ IF v_n = 1 THEN
57
+ RETURN v_tenant;
58
+ END IF;
59
+ RETURN NULL;
60
+ END $$;
61
+
62
+ -- ── unit_scoping_on() — the per-account switch main already had ─────────
63
+ -- `039_unit_tree` gave every tenant a `unit_scoping_enabled`, off by default,
64
+ -- and that is a better design than the always-on this chain originally shipped:
65
+ -- a network turns it on when it is ready, and turning it off gives everything
66
+ -- back without moving a row. The policy template honours that switch instead of
67
+ -- replacing it.
68
+ --
69
+ -- Switch off ⇒ every unit of the account is yours, which is exactly the
70
+ -- behaviour from before units existed.
71
+ CREATE OR REPLACE FUNCTION app.unit_scoping_on()
72
+ RETURNS boolean
73
+ LANGUAGE sql STABLE SECURITY DEFINER
74
+ SET search_path = ''
75
+ AS $$
76
+ SELECT coalesce((SELECT t.unit_scoping_enabled FROM public.tenants t
77
+ WHERE t.id = app.current_tenant_id()), false);
78
+ $$;
79
+ REVOKE ALL ON FUNCTION app.unit_scoping_on() FROM public, anon;
80
+ GRANT EXECUTE ON FUNCTION app.unit_scoping_on() TO authenticated, service_role;
81
+
82
+ -- ── has_unit(unit) ──────────────────────────────────────────────────────
83
+ -- O(1) on the share table's PK. A NULL unit is a tenant-wide resource: the
84
+ -- template checks `unit_id IS NULL OR has_unit(unit_id)` itself.
85
+ CREATE OR REPLACE FUNCTION app.has_unit(p_unit uuid)
86
+ RETURNS boolean
87
+ LANGUAGE sql STABLE SECURITY DEFINER
88
+ SET search_path = ''
89
+ AS $$
90
+ SELECT p_unit IS NOT NULL
91
+ AND CASE WHEN app.unit_scoping_on()
92
+ THEN EXISTS (SELECT 1 FROM app.user_unit_access a
93
+ WHERE a.tenant_id = app.current_tenant_id()
94
+ AND a.user_id = auth.uid()
95
+ AND a.unit_id = p_unit)
96
+ -- switch off: every unit of the account is yours
97
+ ELSE EXISTS (SELECT 1 FROM app.units u
98
+ WHERE u.id = p_unit AND u.tenant_id = app.current_tenant_id())
99
+ END;
100
+ $$;
101
+
102
+ -- ── effective_role_id(tenant, user, unit) ───────────────────────────────
103
+ -- §8: membership_units.role_override_id when present for (user, unit), else
104
+ -- memberships.role_id. Inactive membership ⇒ no role.
105
+ CREATE OR REPLACE FUNCTION app.effective_role_id(p_tenant uuid, p_user uuid, p_unit uuid DEFAULT NULL)
106
+ RETURNS uuid
107
+ LANGUAGE sql STABLE SECURITY DEFINER
108
+ SET search_path = ''
109
+ AS $$
110
+ SELECT coalesce(
111
+ (SELECT mu.role_override_id
112
+ FROM app.membership_units mu
113
+ WHERE p_unit IS NOT NULL
114
+ AND mu.tenant_id = p_tenant AND mu.user_id = p_user AND mu.unit_id = p_unit
115
+ AND mu.role_override_id IS NOT NULL),
116
+ (SELECT m.role_id
117
+ FROM app.memberships m
118
+ WHERE m.tenant_id = p_tenant AND m.user_id = p_user AND m.active)
119
+ );
120
+ $$;
121
+
122
+ -- ── has_permission(perm[, unit]) ────────────────────────────────────────
123
+ -- Resolves against the effective role for the row's unit when one is given,
124
+ -- else the base role. `{feature}.manage` implies every `{feature}.{action}`
125
+ -- (the SDK's resolveAccess semantics, mirrored — same source, no drift). No
126
+ -- owner bypass: an owner is a role whose permission set is the whole catalog.
127
+ CREATE OR REPLACE FUNCTION app.has_permission(p_perm text, p_unit uuid DEFAULT NULL)
128
+ RETURNS boolean
129
+ LANGUAGE sql STABLE SECURITY DEFINER
130
+ SET search_path = ''
131
+ AS $$
132
+ SELECT EXISTS (
133
+ SELECT 1
134
+ FROM app.role_permissions rp
135
+ WHERE rp.tenant_id = app.current_tenant_id()
136
+ AND rp.role_id = app.effective_role_id(app.current_tenant_id(), auth.uid(), p_unit)
137
+ AND rp.permission IN (p_perm, regexp_replace(p_perm, '\.[^.]+$', '.manage'))
138
+ );
139
+ $$;
140
+
141
+ -- Does the user hold the permission in ANY of their roles (base or any unit
142
+ -- override)? For UI affordances and tenant-wide actions, not for row scope.
143
+ CREATE OR REPLACE FUNCTION app.has_permission_anywhere(p_perm text)
144
+ RETURNS boolean
145
+ LANGUAGE sql STABLE SECURITY DEFINER
146
+ SET search_path = ''
147
+ AS $$
148
+ SELECT EXISTS (
149
+ SELECT 1
150
+ FROM app.role_permissions rp
151
+ WHERE rp.tenant_id = app.current_tenant_id()
152
+ AND rp.permission IN (p_perm, regexp_replace(p_perm, '\.[^.]+$', '.manage'))
153
+ AND rp.role_id IN (
154
+ SELECT m.role_id FROM app.memberships m
155
+ WHERE m.tenant_id = rp.tenant_id AND m.user_id = auth.uid() AND m.active
156
+ UNION
157
+ SELECT mu.role_override_id FROM app.membership_units mu
158
+ WHERE mu.tenant_id = rp.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL
159
+ )
160
+ );
161
+ $$;
162
+
163
+ -- ── has_grant(type, id, relations[]) ────────────────────────────────────
164
+ -- resource_grants where the subject is me, one of my units, or one of my roles.
165
+ CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
166
+ RETURNS boolean
167
+ LANGUAGE sql STABLE SECURITY DEFINER
168
+ SET search_path = ''
169
+ AS $$
170
+ SELECT EXISTS (
171
+ SELECT 1
172
+ FROM app.resource_grants g
173
+ WHERE g.tenant_id = app.current_tenant_id()
174
+ AND g.resource_type = p_type
175
+ AND g.resource_id = p_id
176
+ AND g.relation = ANY (p_relations)
177
+ AND (
178
+ (g.subject_type = 'user' AND g.subject_id = auth.uid())
179
+ OR (g.subject_type = 'unit' AND EXISTS (
180
+ SELECT 1 FROM app.user_unit_access a
181
+ WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid() AND a.unit_id = g.subject_id))
182
+ OR (g.subject_type = 'role' AND g.subject_id IN (
183
+ SELECT m.role_id FROM app.memberships m
184
+ WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
185
+ UNION
186
+ SELECT mu.role_override_id FROM app.membership_units mu
187
+ WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
188
+ )
189
+ );
190
+ $$;
191
+
192
+ -- "No available_in tuples ⇒ available everywhere" (§9.1): a tenant-wide
193
+ -- resource is visible in a unit unless the resource has been restricted, in
194
+ -- which case the unit must be one of the granted ones.
195
+ CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
196
+ RETURNS boolean
197
+ LANGUAGE sql STABLE SECURITY DEFINER
198
+ SET search_path = ''
199
+ AS $$
200
+ SELECT NOT EXISTS (
201
+ SELECT 1 FROM app.resource_grants g
202
+ WHERE g.tenant_id = app.current_tenant_id()
203
+ AND g.resource_type = p_type AND g.resource_id = p_id AND g.relation = 'available_in')
204
+ OR app.has_grant(p_type, p_id, ARRAY['available_in']);
205
+ $$;
206
+
207
+ -- ── is_member(tenant) ───────────────────────────────────────────────────
208
+ -- Plain active membership, no permission — what the authz tables' own read
209
+ -- policies need ("members see their tenant's units and roles").
210
+ CREATE OR REPLACE FUNCTION app.is_member(p_tenant uuid)
211
+ RETURNS boolean
212
+ LANGUAGE sql STABLE SECURITY DEFINER
213
+ SET search_path = ''
214
+ AS $$
215
+ SELECT EXISTS (
216
+ SELECT 1 FROM app.memberships m
217
+ WHERE m.tenant_id = p_tenant AND m.user_id = auth.uid() AND m.active
218
+ );
219
+ $$;
220
+
221
+ REVOKE ALL ON FUNCTION app.current_tenant_id() FROM public;
222
+ REVOKE ALL ON FUNCTION app.has_unit(uuid) FROM public;
223
+ REVOKE ALL ON FUNCTION app.effective_role_id(uuid, uuid, uuid) FROM public;
224
+ REVOKE ALL ON FUNCTION app.has_permission(text, uuid) FROM public;
225
+ REVOKE ALL ON FUNCTION app.has_permission_anywhere(text) FROM public;
226
+ REVOKE ALL ON FUNCTION app.has_grant(text, uuid, text[]) FROM public;
227
+ REVOKE ALL ON FUNCTION app.available_in_my_units(text, uuid) FROM public;
228
+ REVOKE ALL ON FUNCTION app.is_member(uuid) FROM public;
229
+ GRANT EXECUTE ON FUNCTION app.current_tenant_id() TO authenticated, service_role;
230
+ GRANT EXECUTE ON FUNCTION app.has_unit(uuid) TO authenticated, service_role;
231
+ GRANT EXECUTE ON FUNCTION app.effective_role_id(uuid, uuid, uuid) TO authenticated, service_role;
232
+ GRANT EXECUTE ON FUNCTION app.has_permission(text, uuid) TO authenticated, service_role;
233
+ GRANT EXECUTE ON FUNCTION app.has_permission_anywhere(text) TO authenticated, service_role;
234
+ GRANT EXECUTE ON FUNCTION app.has_grant(text, uuid, text[]) TO authenticated, service_role;
235
+ GRANT EXECUTE ON FUNCTION app.available_in_my_units(text, uuid) TO authenticated, service_role;
236
+ GRANT EXECUTE ON FUNCTION app.is_member(uuid) TO authenticated, service_role;
237
+
238
+ -- ═══════════════════════ RLS on the authz tables ══════════════════════════
239
+ -- Reads: any active member of the tenant sees the tenant's units, roles and
240
+ -- role permissions (the UI needs them to render); memberships and unit rows are
241
+ -- visible to the member herself and to holders of members.read; the share table
242
+ -- to the user herself and to members.read; grants to members of the tenant.
243
+ -- Writes: platform permission keys — no owner/admin literal anywhere.
244
+
245
+ -- units
246
+ DROP POLICY IF EXISTS units_select ON app.units;
247
+ CREATE POLICY units_select ON app.units FOR SELECT TO authenticated
248
+ USING ((select app.is_member(tenant_id)));
249
+ DROP POLICY IF EXISTS units_insert ON app.units;
250
+ CREATE POLICY units_insert ON app.units FOR INSERT TO authenticated
251
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
252
+ DROP POLICY IF EXISTS units_update ON app.units;
253
+ CREATE POLICY units_update ON app.units FOR UPDATE TO authenticated
254
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')))
255
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
256
+ DROP POLICY IF EXISTS units_delete ON app.units;
257
+ CREATE POLICY units_delete ON app.units FOR DELETE TO authenticated
258
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('units.manage')));
259
+
260
+ -- roles
261
+ DROP POLICY IF EXISTS roles_select ON app.roles;
262
+ CREATE POLICY roles_select ON app.roles FOR SELECT TO authenticated
263
+ USING ((select app.is_member(tenant_id)));
264
+ DROP POLICY IF EXISTS roles_insert ON app.roles;
265
+ CREATE POLICY roles_insert ON app.roles FOR INSERT TO authenticated
266
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')) AND NOT is_system);
267
+ DROP POLICY IF EXISTS roles_update ON app.roles;
268
+ CREATE POLICY roles_update ON app.roles FOR UPDATE TO authenticated
269
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')))
270
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')));
271
+ DROP POLICY IF EXISTS roles_delete ON app.roles;
272
+ CREATE POLICY roles_delete ON app.roles FOR DELETE TO authenticated
273
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')) AND NOT is_system);
274
+
275
+ -- role_permissions
276
+ DROP POLICY IF EXISTS role_permissions_select ON app.role_permissions;
277
+ CREATE POLICY role_permissions_select ON app.role_permissions FOR SELECT TO authenticated
278
+ USING ((select app.is_member(tenant_id)));
279
+ DROP POLICY IF EXISTS role_permissions_write ON app.role_permissions;
280
+ CREATE POLICY role_permissions_write ON app.role_permissions FOR ALL TO authenticated
281
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')))
282
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('roles.manage')));
283
+
284
+ -- memberships: yourself, or members.read; writes need members.manage and can
285
+ -- never be a self-insert (the P0 of the old model) — creation of the first
286
+ -- member is the create_tenant RPC's job (#134).
287
+ DROP POLICY IF EXISTS memberships_select ON app.memberships;
288
+ CREATE POLICY memberships_select ON app.memberships FOR SELECT TO authenticated
289
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
290
+ DROP POLICY IF EXISTS memberships_insert ON app.memberships;
291
+ CREATE POLICY memberships_insert ON app.memberships FOR INSERT TO authenticated
292
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
293
+ DROP POLICY IF EXISTS memberships_update ON app.memberships;
294
+ CREATE POLICY memberships_update ON app.memberships FOR UPDATE TO authenticated
295
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
296
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
297
+ DROP POLICY IF EXISTS memberships_delete ON app.memberships;
298
+ CREATE POLICY memberships_delete ON app.memberships FOR DELETE TO authenticated
299
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')) AND user_id <> (select auth.uid()));
300
+
301
+ -- membership_units
302
+ DROP POLICY IF EXISTS membership_units_select ON app.membership_units;
303
+ CREATE POLICY membership_units_select ON app.membership_units FOR SELECT TO authenticated
304
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
305
+ DROP POLICY IF EXISTS membership_units_write ON app.membership_units;
306
+ CREATE POLICY membership_units_write ON app.membership_units FOR ALL TO authenticated
307
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')))
308
+ WITH CHECK (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.manage')));
309
+
310
+ -- user_unit_access: read-only surface (the share table)
311
+ DROP POLICY IF EXISTS user_unit_access_select ON app.user_unit_access;
312
+ CREATE POLICY user_unit_access_select ON app.user_unit_access FOR SELECT TO authenticated
313
+ USING (user_id = (select auth.uid()) OR (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read'))));
314
+
315
+ -- resource_grants: members read; writing a tuple needs the platform key
316
+ -- authz.grants.manage or the resource's own '{plugin}.manage' (§12.9 default).
317
+ DROP POLICY IF EXISTS resource_grants_select ON app.resource_grants;
318
+ CREATE POLICY resource_grants_select ON app.resource_grants FOR SELECT TO authenticated
319
+ USING ((select app.is_member(tenant_id)));
320
+ DROP POLICY IF EXISTS resource_grants_write ON app.resource_grants;
321
+ CREATE POLICY resource_grants_write ON app.resource_grants FOR ALL TO authenticated
322
+ USING (tenant_id = (select app.current_tenant_id())
323
+ AND ((select app.has_permission('authz.grants.manage')) OR (select app.has_permission(split_part(resource_type, '.', 1) || '.manage'))))
324
+ WITH CHECK (tenant_id = (select app.current_tenant_id())
325
+ AND ((select app.has_permission('authz.grants.manage')) OR (select app.has_permission(split_part(resource_type, '.', 1) || '.manage'))));
326
+
327
+ -- reconciliation log: readable by members.read of the tenant
328
+ DROP POLICY IF EXISTS authz_reconciliation_log_select ON app.authz_reconciliation_log;
329
+ CREATE POLICY authz_reconciliation_log_select ON app.authz_reconciliation_log FOR SELECT TO authenticated
330
+ USING (tenant_id = (select app.current_tenant_id()) AND (select app.has_permission('members.read')));