@fayz-ai/db 0.13.0 → 0.14.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 +106 -23
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +42 -37
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/000_baseline.sql +29667 -0
- package/migrations/001_the_tenant_has_apps.sql +314 -0
- package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
- package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
- package/migrations/004_the_projections_get_a_successor.sql +312 -0
- package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
- package/migrations/006_the_helpers_read_the_source.sql +153 -0
- package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
- package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
- package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
- package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
- package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
- package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
- package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
- package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
- package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
- package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
- package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
- package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
- package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
- package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
- package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
- package/package.json +1 -1
- package/migrations/0000_legacy_quarantine.sql +0 -86
- package/migrations/0000b_straggler_sweep.sql +0 -37
- package/migrations/000_core_v1_convert.sql +0 -343
- package/migrations/001_core.sql +0 -372
- package/migrations/002_rls_user_tenant_ids.sql +0 -64
- package/migrations/003_invitations.sql +0 -17
- package/migrations/004_archetypes.sql +0 -343
- package/migrations/005_locations_archetype.sql +0 -18
- package/migrations/006_archetype_rls.sql +0 -74
- package/migrations/007_archetype_grants.sql +0 -4
- package/migrations/008_grants.sql +0 -5
- package/migrations/009_anon_hardening.sql +0 -19
- package/migrations/010_migration_ledger.sql +0 -46
- package/migrations/011_anon_write_revoke.sql +0 -12
- package/migrations/012_rename_migration_ledger.sql +0 -61
- package/migrations/013_sequences_rls.sql +0 -22
- package/migrations/014_plan_entitlements.sql +0 -28
- package/migrations/015_agent_guard.sql +0 -108
- package/migrations/016_agent_guard_actor.sql +0 -102
- package/migrations/017_core_addresses.sql +0 -115
- package/migrations/018_global_search.sql +0 -482
- package/migrations/019_tenant_members_person_id.sql +0 -20
- package/migrations/020_fayz_projects.sql +0 -63
- package/migrations/021_tenant_roles.sql +0 -74
- package/migrations/022_invite_acceptance.sql +0 -91
- package/migrations/023_team_visibility.sql +0 -75
- package/migrations/024_analytics_engine.sql +0 -317
- package/migrations/025_created_by.sql +0 -75
- package/migrations/026_audit_trail.sql +0 -73
- package/migrations/027_domain_events.sql +0 -266
- package/migrations/028_tenant_scoped_token.sql +0 -127
- package/migrations/029_connections.sql +0 -186
- package/migrations/030_effect_idempotency.sql +0 -159
- package/migrations/031_sync_run_message.sql +0 -39
- package/migrations/032_connection_secrets.sql +0 -227
- package/migrations/033_sync_schedule.sql +0 -651
- package/migrations/034_custom_fields.sql +0 -55
- package/migrations/035_field_registry.sql +0 -148
- package/migrations/036_analytics_run_batch.sql +0 -84
- package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
- package/migrations/038_onboarding_responses.sql +0 -103
- package/migrations/039_unit_tree.sql +0 -270
- package/migrations/040_resource_grants.sql +0 -474
- package/migrations/041_scoped_columns.sql +0 -192
- package/migrations/042_unit_scope_policies.sql +0 -145
- package/migrations/043_view_invoker.sql +0 -90
- package/migrations/044_unit_member_facts.sql +0 -47
- package/migrations/045_unit_entry.sql +0 -236
- package/migrations/046_membership_visible_to_members.sql +0 -85
- package/migrations/047_tasks.sql +0 -266
- package/migrations/048_every_login_is_a_person.sql +0 -190
- package/migrations/049_bookable_people.sql +0 -126
- package/migrations/050_authz_core.sql +0 -230
- package/migrations/051_authz_helpers.sql +0 -330
- package/migrations/052_authz_fanout.sql +0 -253
- package/migrations/053_authz_catalog_templates.sql +0 -402
- package/migrations/060_tenancy_seam.sql +0 -132
- package/migrations/061_create_tenant.sql +0 -125
- package/migrations/062_invitations.sql +0 -329
- package/migrations/063_legacy_tenancy_retired.sql +0 -568
- package/migrations/064_agent_guard_authz.sql +0 -118
- package/migrations/064b_supersede_unit_scope.sql +0 -205
- package/migrations/065_scaffold_sweep.sql +0 -139
- package/migrations/066_shadow_report.sql +0 -122
- package/migrations/070_permission_catalog.sql +0 -205
- package/migrations/071_v1_permission_map.sql +0 -266
- package/migrations/072_role_rpcs.sql +0 -126
- package/migrations/073_legacy_rbac_retire.sql +0 -471
- package/migrations/075_company_legal_entities.sql +0 -169
- package/migrations/076_tenant_settings.sql +0 -251
- package/migrations/080_people_roles.sql +0 -269
- package/migrations/081_people_identity.sql +0 -271
- package/migrations/082_people_membership_link.sql +0 -142
- package/migrations/083_people_data_migration.sql +0 -75
- package/migrations/084_people_registrations_tokens.sql +0 -282
- package/migrations/085_catalog_products.sql +0 -333
- package/migrations/086_catalog_pricing.sql +0 -194
- package/migrations/087_catalog_packages.sql +0 -134
- package/migrations/090_orders_venda.sql +0 -166
- package/migrations/091_orders_scaffold.sql +0 -92
- package/migrations/095_config_field_rules.sql +0 -261
- package/migrations/096_audit_append_only.sql +0 -256
- package/migrations/097_sequences_authorized.sql +0 -111
- package/migrations/098_holidays.sql +0 -80
- package/migrations/099_access_grants.sql +0 -293
- package/migrations/100_migration_schema.sql +0 -217
- package/migrations/101_migration_allowlist.sql +0 -202
- package/migrations/102_migration_rpcs.sql +0 -933
- package/migrations/103_migration_reconcile_ponr.sql +0 -132
- package/migrations/105_policies_to_authenticated.sql +0 -71
- package/migrations/106_function_search_path.sql +0 -55
- package/migrations/107_core_table_prefix.sql +0 -361
- package/migrations/108_authz_write_guards.sql +0 -149
- package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
- package/migrations/110_people_kind_vocabulary.sql +0 -120
- package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
- package/migrations/112_money_precision_standard.sql +0 -132
- package/migrations/113_addresses_units_companies.sql +0 -114
- package/migrations/114_migrations_ledger_immutable.sql +0 -65
- package/migrations/115_registration_honours_field_rules.sql +0 -172
- package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
- package/migrations/117_authz_reconciliation_schedule.sql +0 -82
- package/migrations/118_migration_catalog_writers.sql +0 -450
- package/migrations/119_migration_config_writers.sql +0 -193
- package/migrations/120_migration_facet_writer.sql +0 -139
- package/migrations/121_migration_financial_writers.sql +0 -294
- package/migrations/122_migration_inventory_writers.sql +0 -245
- package/migrations/123_migration_order_item_writer.sql +0 -144
- package/migrations/124_migration_ledger_writers.sql +0 -170
- package/migrations/125_migration_authz_people_writers.sql +0 -205
- package/migrations/126_owner_scoping_is_configuration.sql +0 -247
- package/migrations/127_scaffold_audit_hook.sql +0 -98
- package/migrations/128_shadow_tables_enforced.sql +0 -93
- package/migrations/129_order_kind_terminal_states.sql +0 -57
- package/migrations/130_every_login_is_a_person.sql +0 -196
- package/migrations/131_migration_invoice_split.sql +0 -127
- package/migrations/132_audit_prune_scheduled.sql +0 -28
- package/migrations/133_unit_is_a_session_property.sql +0 -155
- package/migrations/134_field_rules_on_read.sql +0 -341
- package/migrations/135_distribution_polarity.sql +0 -158
- package/migrations/136_unit_id_means_locality.sql +0 -358
- package/migrations/137_vendor_named_spine_objects.sql +0 -126
- package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
- package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
- package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
- package/migrations/142_a_person_has_addresses.sql +0 -212
- package/migrations/143_a_professional_has_a_rate.sql +0 -75
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
- package/migrations/146_products_stock_has_one_writer.sql +0 -67
- package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
- package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
- package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
- package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
- package/migrations/152_a_file_is_an_archetype.sql +0 -222
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
- package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
- package/migrations/155_a_tenant_can_leave.sql +0 -271
- package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
- package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
- package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
- package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
- package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
- package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
- package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
- package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 134_field_rules_on_read.sql — a hidden field stops being a front-end
|
|
3
|
-
-- convention (#221).
|
|
4
|
-
--
|
|
5
|
-
-- `field_rules` (085) already models this: (tenant, unit, context, reference,
|
|
6
|
-
-- field_key, state hidden|visible|required, role_key), migrated from V1's
|
|
7
|
-
-- field_visibility_rules, with a per-unit override for free. "Reception does not
|
|
8
|
-
-- see the patient's phone" is ONE ROW, set by the tenant admin, no deploy.
|
|
9
|
-
--
|
|
10
|
-
-- What was missing is enforcement on READ. `field_rules_check` runs on the write
|
|
11
|
-
-- path (105/106) and validates `required`; `hidden` was honoured by the front
|
|
12
|
-
-- end, so the value was in the PostgREST response.
|
|
13
|
-
--
|
|
14
|
-
-- RLS cannot close this — it is row-level by construction, and no policy hides a
|
|
15
|
-
-- column. Column privileges cannot either: GRANT SELECT (col) is per DATABASE
|
|
16
|
-
-- role, and every logged-in user is `authenticated`.
|
|
17
|
-
--
|
|
18
|
-
-- WHY THE VIEW CARRIES THE PREDICATE INSTEAD OF `security_invoker`
|
|
19
|
-
--
|
|
20
|
-
-- `security_invoker = true` keeps RLS working, but it also means the caller
|
|
21
|
-
-- needs SELECT on the base table — which is exactly the privilege the boundary
|
|
22
|
-
-- has to remove. Owner rights is the other half of the trade: no table privilege
|
|
23
|
-
-- needed, but `postgres` holds BYPASSRLS, so the row filter has to be IN THE
|
|
24
|
-
-- VIEW. That is not a new pattern here: `v_public_services` (plugin-agenda 015)
|
|
25
|
-
-- made the same call for the same reason.
|
|
26
|
-
--
|
|
27
|
-
-- The duplication that normally makes this a bad idea is removed at the source:
|
|
28
|
-
-- `app.scaffold_scope_sql()` is extracted from 116 and is now the ONE place the
|
|
29
|
-
-- row predicate is written. `scaffold_table`, `scaffold_refresh_policies` and
|
|
30
|
-
-- the masked view all render from it, so the view cannot drift from the policy —
|
|
31
|
-
-- they are the same string.
|
|
32
|
-
--
|
|
33
|
-
-- The mask itself resolves ONCE PER STATEMENT, not once per row: the hidden set
|
|
34
|
-
-- is `(select app.hidden_fields(…))`, which the planner lifts to an InitPlan —
|
|
35
|
-
-- the same trick the authz helpers have used since 041. That is only affordable
|
|
36
|
-
-- because #220 gave the session one unit; a per-row unit would mean a per-row
|
|
37
|
-
-- rule lookup.
|
|
38
|
-
--
|
|
39
|
-
-- What this file does NOT do: revoke the base table. That is the step that turns
|
|
40
|
-
-- the view from available into mandatory, it has its own blast radius, and it
|
|
41
|
-
-- gets its own migration.
|
|
42
|
-
--
|
|
43
|
-
-- Replay-safe: CREATE OR REPLACE + a generated view rebuilt from the catalog.
|
|
44
|
-
-- ============================================================================
|
|
45
|
-
|
|
46
|
-
-- ── the row predicate, written once ─────────────────────────────────────
|
|
47
|
-
-- Lifted verbatim out of 126's scaffold_table / scaffold_refresh_policies. The
|
|
48
|
-
-- string it returns is byte-identical to the one those two built inline, so the
|
|
49
|
-
-- policies this migration re-emits are the policies that were already there.
|
|
50
|
-
-- The third parameter is defaulted and unused here: 126 gives `unit_id` a
|
|
51
|
-
-- declared class per table, and this is the one place the predicate is written,
|
|
52
|
-
-- so the signature is the final one from the start. Defining the 2-arg form now
|
|
53
|
-
-- and widening it later would leave both overloads alive on a replay — 124 would
|
|
54
|
-
-- recreate the narrow one beside 136's wide one, and every 2-arg call would stop
|
|
55
|
-
-- resolving. That is not hypothetical: it is what the force-replay pass caught.
|
|
56
|
-
CREATE OR REPLACE FUNCTION app.scaffold_scope_sql(p_resource_type text, p_plugin text, p_scope text DEFAULT NULL)
|
|
57
|
-
RETURNS text
|
|
58
|
-
LANGUAGE sql IMMUTABLE
|
|
59
|
-
SET search_path = ''
|
|
60
|
-
AS $$
|
|
61
|
-
SELECT format(
|
|
62
|
-
'tenant_id = (select app.current_tenant_id())'
|
|
63
|
-
|| CASE WHEN p_scope = 'unit'
|
|
64
|
-
THEN ' AND (select app.has_unit(unit_id))'
|
|
65
|
-
ELSE ' AND (unit_id IS NULL OR (select app.has_unit(unit_id)))' END
|
|
66
|
-
|| ' AND (NOT (select app.owner_scoped(%L))'
|
|
67
|
-
|| ' OR owner_id IS NULL OR owner_id = (select auth.uid())'
|
|
68
|
-
|| ' OR (select app.has_grant(%L, id, ARRAY[''shared_with'']))'
|
|
69
|
-
|| ' OR (select app.has_permission(%L, unit_id)))',
|
|
70
|
-
p_resource_type, p_resource_type, p_plugin || '.read_all');
|
|
71
|
-
$$;
|
|
72
|
-
REVOKE ALL ON FUNCTION app.scaffold_scope_sql(text, text, text) FROM public, anon, authenticated;
|
|
73
|
-
|
|
74
|
-
COMMENT ON FUNCTION app.scaffold_scope_sql(text, text, text) IS
|
|
75
|
-
'The row-scope predicate of the §4/§7 template, as text. The ONE place it is written: scaffold_table, scaffold_refresh_policies and app.masked_view_refresh all render from it, so a masked view can never drift from the policy it mirrors (#221).';
|
|
76
|
-
|
|
77
|
-
-- ── which fields are hidden from me, right now ──────────────────────────
|
|
78
|
-
-- Thin on purpose: field_rules_resolve (085) already resolves tenant, effective
|
|
79
|
-
-- role per unit, the unit override and its precedence, and even the
|
|
80
|
-
-- `available_in` release on the RULE itself. This only picks the hidden ones and
|
|
81
|
-
-- returns an array, which is the shape a per-statement InitPlan wants.
|
|
82
|
-
--
|
|
83
|
-
-- The unit is the SESSION's unit (#220). Without a focus there is no unit whose
|
|
84
|
-
-- override could apply — "which branch's rule?" has no answer — so an unfocused
|
|
85
|
-
-- session gets the tenant-level rules. That is the honest reading, not a
|
|
86
|
-
-- fallback.
|
|
87
|
-
CREATE OR REPLACE FUNCTION app.hidden_fields(p_context text, p_reference text)
|
|
88
|
-
RETURNS text[]
|
|
89
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
90
|
-
SET search_path = ''
|
|
91
|
-
AS $$
|
|
92
|
-
SELECT coalesce(array_agg(r.field_key ORDER BY r.field_key), '{}'::text[])
|
|
93
|
-
FROM public.field_rules_resolve(
|
|
94
|
-
p_context, p_reference,
|
|
95
|
-
nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid)) r
|
|
96
|
-
WHERE r.state = 'hidden';
|
|
97
|
-
$$;
|
|
98
|
-
REVOKE ALL ON FUNCTION app.hidden_fields(text, text) FROM public, anon;
|
|
99
|
-
GRANT EXECUTE ON FUNCTION app.hidden_fields(text, text) TO authenticated, service_role;
|
|
100
|
-
|
|
101
|
-
COMMENT ON FUNCTION app.hidden_fields(text, text) IS
|
|
102
|
-
'The field_keys `field_rules` hides from the caller, for the session''s unit (#221). Call it as (select app.hidden_fields(…)) so it resolves once per statement rather than once per row.';
|
|
103
|
-
|
|
104
|
-
-- ── the registry of masked views ────────────────────────────────────────
|
|
105
|
-
-- N16 refuses an owner-rights view over a tenant_id table, and it is right to:
|
|
106
|
-
-- an owner-invoked view IS an RLS bypass. A masked view is owner-rights on
|
|
107
|
-
-- purpose, so it needs to be distinguishable from the mistake N16 exists to
|
|
108
|
-
-- catch — and a name on a hand-maintained allowlist is not a distinction, it is
|
|
109
|
-
-- a hole with a comment next to it.
|
|
110
|
-
--
|
|
111
|
-
-- So the exception is earned, not granted: only app.masked_view_refresh writes
|
|
112
|
-
-- here, and 900_verification asserts that every view listed carries the tenant
|
|
113
|
-
-- filter and the read permission in its own body. Registering buys nothing on
|
|
114
|
-
-- its own.
|
|
115
|
-
CREATE TABLE IF NOT EXISTS app.masked_views (
|
|
116
|
-
table_schema text NOT NULL,
|
|
117
|
-
table_name text NOT NULL,
|
|
118
|
-
view_name text NOT NULL,
|
|
119
|
-
context text NOT NULL,
|
|
120
|
-
reference text NOT NULL,
|
|
121
|
-
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
122
|
-
PRIMARY KEY (table_schema, table_name)
|
|
123
|
-
);
|
|
124
|
-
ALTER TABLE app.masked_views ENABLE ROW LEVEL SECURITY;
|
|
125
|
-
GRANT SELECT ON app.masked_views TO authenticated;
|
|
126
|
-
DROP POLICY IF EXISTS masked_views_select ON app.masked_views;
|
|
127
|
-
CREATE POLICY masked_views_select ON app.masked_views FOR SELECT TO authenticated USING (true);
|
|
128
|
-
COMMENT ON TABLE app.masked_views IS
|
|
129
|
-
'Which tables read through a field_rules-masked view, and under which context/reference (#221). Written only by app.masked_view_refresh; 900_verification/001 checks that each listed view actually carries the row predicate, so a row here is not a bypass permit.';
|
|
130
|
-
|
|
131
|
-
-- ── generate the masked read view for a scaffolded table ────────────────
|
|
132
|
-
-- Structural columns pass through untouched — the view is still joined on and
|
|
133
|
-
-- filtered by them. Everything else is wrapped so a hidden field reads NULL.
|
|
134
|
-
--
|
|
135
|
-
-- DROP + CREATE rather than CREATE OR REPLACE: replace refuses a changed column
|
|
136
|
-
-- list, and a table that loses a column has to be able to lose it here too.
|
|
137
|
-
CREATE OR REPLACE FUNCTION app.masked_view_refresh(
|
|
138
|
-
p_table regclass,
|
|
139
|
-
p_context text,
|
|
140
|
-
p_reference text
|
|
141
|
-
)
|
|
142
|
-
RETURNS void
|
|
143
|
-
LANGUAGE plpgsql SECURITY DEFINER
|
|
144
|
-
SET search_path = ''
|
|
145
|
-
AS $$
|
|
146
|
-
DECLARE
|
|
147
|
-
v_schema text;
|
|
148
|
-
v_name text;
|
|
149
|
-
v_view text;
|
|
150
|
-
r app.scaffold_registry%ROWTYPE;
|
|
151
|
-
v_cols text;
|
|
152
|
-
v_structural constant text[] := ARRAY['id','tenant_id','unit_id','owner_id','created_at','updated_at'];
|
|
153
|
-
BEGIN
|
|
154
|
-
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
155
|
-
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
156
|
-
|
|
157
|
-
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
158
|
-
IF NOT FOUND THEN
|
|
159
|
-
RAISE EXCEPTION 'masked_view_refresh: %.% is not scaffolded — the view renders the scaffold''s own predicate', v_schema, v_name
|
|
160
|
-
USING ERRCODE = 'P0002';
|
|
161
|
-
END IF;
|
|
162
|
-
IF NOT EXISTS (SELECT 1 FROM public.field_rule_contexts WHERE key = p_context) THEN
|
|
163
|
-
RAISE EXCEPTION 'masked_view_refresh: unknown field-rule context %', p_context USING ERRCODE = '23503';
|
|
164
|
-
END IF;
|
|
165
|
-
|
|
166
|
-
v_view := 'v_' || v_name;
|
|
167
|
-
|
|
168
|
-
SELECT string_agg(
|
|
169
|
-
CASE WHEN c.column_name = ANY (v_structural)
|
|
170
|
-
THEN format('%I', c.column_name)
|
|
171
|
-
-- `@>` and not `= ANY (…)`: a parenthesised scalar subquery is
|
|
172
|
-
-- parsed as ANY's SUBQUERY form, which compares against rows and
|
|
173
|
-
-- chokes on a text[]. Containment takes the array directly and
|
|
174
|
-
-- still plans as a once-per-statement InitPlan.
|
|
175
|
-
ELSE format('CASE WHEN NOT ((select app.hidden_fields(%L, %L)) @> ARRAY[%L::text]) THEN %I END AS %I',
|
|
176
|
-
p_context, p_reference, c.column_name, c.column_name, c.column_name)
|
|
177
|
-
END, E',\n ' ORDER BY c.ordinal_position)
|
|
178
|
-
INTO v_cols
|
|
179
|
-
FROM information_schema.columns c
|
|
180
|
-
WHERE c.table_schema = v_schema AND c.table_name = v_name;
|
|
181
|
-
|
|
182
|
-
EXECUTE format('DROP VIEW IF EXISTS %I.%I', v_schema, v_view);
|
|
183
|
-
-- Owner rights on purpose (see the header): the predicate below IS the
|
|
184
|
-
-- scaffold's, rendered from the same function the policies render from.
|
|
185
|
-
EXECUTE format(
|
|
186
|
-
'CREATE VIEW %I.%I AS SELECT %s FROM %I.%I WHERE %s AND (select app.has_permission(%L, unit_id))',
|
|
187
|
-
v_schema, v_view, v_cols, v_schema, v_name,
|
|
188
|
-
app.scaffold_scope_sql(r.resource_type, r.plugin), r.plugin || '.read');
|
|
189
|
-
|
|
190
|
-
EXECUTE format('REVOKE ALL ON %I.%I FROM public, anon', v_schema, v_view);
|
|
191
|
-
EXECUTE format('GRANT SELECT ON %I.%I TO authenticated, service_role', v_schema, v_view);
|
|
192
|
-
EXECUTE format(
|
|
193
|
-
'COMMENT ON VIEW %I.%I IS %L',
|
|
194
|
-
v_schema, v_view,
|
|
195
|
-
format('Masked read of %I.%I (#221): every non-structural column reads NULL when field_rules hides it for the caller''s role and unit. Owner rights with the scaffold''s own row predicate — regenerate with app.masked_view_refresh(''%I.%I'', %L, %L), never edit by hand.',
|
|
196
|
-
v_schema, v_name, v_schema, v_name, p_context, p_reference));
|
|
197
|
-
|
|
198
|
-
INSERT INTO app.masked_views (table_schema, table_name, view_name, context, reference)
|
|
199
|
-
VALUES (v_schema, v_name, v_view, p_context, p_reference)
|
|
200
|
-
ON CONFLICT (table_schema, table_name) DO UPDATE
|
|
201
|
-
SET view_name = EXCLUDED.view_name, context = EXCLUDED.context,
|
|
202
|
-
reference = EXCLUDED.reference, applied_at = now();
|
|
203
|
-
END $$;
|
|
204
|
-
REVOKE ALL ON FUNCTION app.masked_view_refresh(regclass, text, text) FROM public, anon, authenticated;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
-- ── scaffold_table, re-emitted to render from the shared predicate ──────
|
|
208
|
-
-- 126's function with one assignment changed: the inline format() that built
|
|
209
|
-
-- v_scope becomes the call to app.scaffold_scope_sql(). Everything else is 116
|
|
210
|
-
-- verbatim — this file lifts the text rather than retyping it, so "the policies
|
|
211
|
-
-- are the same policies" is a property of how it was produced, not a claim.
|
|
212
|
-
CREATE OR REPLACE FUNCTION app.scaffold_table(
|
|
213
|
-
p_table regclass,
|
|
214
|
-
p_resource_type text,
|
|
215
|
-
p_plugin text,
|
|
216
|
-
p_owner_scoping boolean DEFAULT true,
|
|
217
|
-
p_mode text DEFAULT 'enforce'
|
|
218
|
-
)
|
|
219
|
-
RETURNS void
|
|
220
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
221
|
-
AS $$
|
|
222
|
-
DECLARE
|
|
223
|
-
v_schema text;
|
|
224
|
-
v_name text;
|
|
225
|
-
v_q text; -- quoted qualified name
|
|
226
|
-
v_prev text[] := '{}';
|
|
227
|
-
v_rows bigint;
|
|
228
|
-
r record;
|
|
229
|
-
v_scope text; -- the row-scope predicate shared by every policy
|
|
230
|
-
BEGIN
|
|
231
|
-
IF p_mode NOT IN ('shadow', 'enforce') THEN
|
|
232
|
-
RAISE EXCEPTION 'scaffold_table: mode must be shadow|enforce';
|
|
233
|
-
END IF;
|
|
234
|
-
SELECT n.nspname, c.relname INTO v_schema, v_name
|
|
235
|
-
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = p_table;
|
|
236
|
-
v_q := format('%I.%I', v_schema, v_name);
|
|
237
|
-
|
|
238
|
-
-- ── columns
|
|
239
|
-
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = v_schema AND table_name = v_name AND column_name = 'id') THEN
|
|
240
|
-
RAISE EXCEPTION 'scaffold_table: % has no id column', v_q;
|
|
241
|
-
END IF;
|
|
242
|
-
EXECUTE format('SELECT count(*) FROM %s', v_q) INTO v_rows;
|
|
243
|
-
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
|
|
244
|
-
EXECUTE format('ALTER TABLE %s ADD COLUMN tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE', v_q);
|
|
245
|
-
IF v_rows = 0 THEN EXECUTE format('ALTER TABLE %s ALTER COLUMN tenant_id SET NOT NULL', v_q); END IF;
|
|
246
|
-
END IF;
|
|
247
|
-
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS unit_id uuid', v_q);
|
|
248
|
-
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS owner_id uuid', v_q);
|
|
249
|
-
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
250
|
-
EXECUTE format('ALTER TABLE %s ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', v_q);
|
|
251
|
-
|
|
252
|
-
-- ── unique(tenant_id, id) — enables composite FKs from other tables
|
|
253
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_tenant_id_id_key') THEN
|
|
254
|
-
EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I UNIQUE (tenant_id, id)', v_q, v_name || '_tenant_id_id_key');
|
|
255
|
-
END IF;
|
|
256
|
-
-- ── composite FKs: unit within tenant, owner a member of the tenant
|
|
257
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_unit_fk') THEN
|
|
258
|
-
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');
|
|
259
|
-
END IF;
|
|
260
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = p_table AND conname = v_name || '_owner_fk') THEN
|
|
261
|
-
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');
|
|
262
|
-
END IF;
|
|
263
|
-
|
|
264
|
-
-- ── tenant-first indexes
|
|
265
|
-
EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %s (tenant_id, unit_id)', v_name || '_tenant_unit_idx', v_q);
|
|
266
|
-
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);
|
|
267
|
-
|
|
268
|
-
-- ── updated_at
|
|
269
|
-
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_name || '_updated_at', v_q);
|
|
270
|
-
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %s FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', v_name || '_updated_at', v_q);
|
|
271
|
-
|
|
272
|
-
-- ── RLS
|
|
273
|
-
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', v_q);
|
|
274
|
-
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', v_q);
|
|
275
|
-
IF p_mode = 'enforce' THEN
|
|
276
|
-
FOR r IN SELECT policyname FROM pg_policies WHERE schemaname = v_schema AND tablename = v_name
|
|
277
|
-
AND policyname NOT IN (v_name || '_authz_select', v_name || '_authz_insert', v_name || '_authz_update', v_name || '_authz_delete') LOOP
|
|
278
|
-
v_prev := v_prev || r.policyname;
|
|
279
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', r.policyname, v_q);
|
|
280
|
-
END LOOP;
|
|
281
|
-
END IF;
|
|
282
|
-
|
|
283
|
-
v_scope := app.scaffold_scope_sql(p_resource_type, p_plugin);
|
|
284
|
-
|
|
285
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
286
|
-
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
287
|
-
v_name || '_authz_select', v_q, v_scope, p_plugin || '.read');
|
|
288
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_insert', v_q);
|
|
289
|
-
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)))',
|
|
290
|
-
v_name || '_authz_insert', v_q, p_plugin || '.create');
|
|
291
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
292
|
-
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)))',
|
|
293
|
-
v_name || '_authz_update', v_q, v_scope, p_plugin || '.edit', p_plugin || '.edit');
|
|
294
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
295
|
-
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
296
|
-
v_name || '_authz_delete', v_q, v_scope, p_plugin || '.delete');
|
|
297
|
-
|
|
298
|
-
INSERT INTO app.scaffold_registry (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies)
|
|
299
|
-
VALUES (v_schema, v_name, p_resource_type, p_plugin, p_owner_scoping, p_mode, v_prev)
|
|
300
|
-
ON CONFLICT (table_schema, table_name) DO UPDATE
|
|
301
|
-
SET resource_type = EXCLUDED.resource_type, plugin = EXCLUDED.plugin, owner_scoping = EXCLUDED.owner_scoping,
|
|
302
|
-
mode = EXCLUDED.mode, applied_at = now(),
|
|
303
|
-
previous_policies = CASE WHEN EXCLUDED.mode = 'enforce' AND app.scaffold_registry.mode = 'shadow'
|
|
304
|
-
THEN app.scaffold_registry.previous_policies || EXCLUDED.previous_policies
|
|
305
|
-
ELSE EXCLUDED.previous_policies END;
|
|
306
|
-
END $$;
|
|
307
|
-
|
|
308
|
-
-- ── scaffold_refresh_policies, same treatment ───────────────────────────
|
|
309
|
-
CREATE OR REPLACE FUNCTION app.scaffold_refresh_policies(p_table text)
|
|
310
|
-
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
311
|
-
DECLARE
|
|
312
|
-
v_schema text := split_part(replace(p_table, '"', ''), '.', 1);
|
|
313
|
-
v_name text := split_part(replace(p_table, '"', ''), '.', 2);
|
|
314
|
-
v_q text := format('%I.%I', split_part(replace(p_table, '"', ''), '.', 1), split_part(replace(p_table, '"', ''), '.', 2));
|
|
315
|
-
r app.scaffold_registry%ROWTYPE;
|
|
316
|
-
v_scope text;
|
|
317
|
-
BEGIN
|
|
318
|
-
SELECT * INTO r FROM app.scaffold_registry WHERE table_schema = v_schema AND table_name = v_name;
|
|
319
|
-
IF NOT FOUND THEN
|
|
320
|
-
RAISE EXCEPTION 'scaffold_refresh_policies: %.% is not in the scaffold registry', v_schema, v_name USING ERRCODE = 'P0002';
|
|
321
|
-
END IF;
|
|
322
|
-
|
|
323
|
-
v_scope := app.scaffold_scope_sql(r.resource_type, r.plugin);
|
|
324
|
-
|
|
325
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_select', v_q);
|
|
326
|
-
EXECUTE format('CREATE POLICY %I ON %s FOR SELECT TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
327
|
-
v_name || '_authz_select', v_q, v_scope, r.plugin || '.read');
|
|
328
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_update', v_q);
|
|
329
|
-
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)))',
|
|
330
|
-
v_name || '_authz_update', v_q, v_scope, r.plugin || '.edit', r.plugin || '.edit');
|
|
331
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON %s', v_name || '_authz_delete', v_q);
|
|
332
|
-
EXECUTE format('CREATE POLICY %I ON %s FOR DELETE TO authenticated USING (%s AND (select app.has_permission(%L, unit_id)))',
|
|
333
|
-
v_name || '_authz_delete', v_q, v_scope, r.plugin || '.delete');
|
|
334
|
-
-- the INSERT policy has no owner clause (there is no row yet to own)
|
|
335
|
-
END $$;
|
|
336
|
-
REVOKE ALL ON FUNCTION app.scaffold_refresh_policies(text) FROM public, anon, authenticated;
|
|
337
|
-
|
|
338
|
-
-- ── the pilot: people ───────────────────────────────────────────────────
|
|
339
|
-
-- context 'person' is 095's own ("The person record itself"); reference 'person'
|
|
340
|
-
-- is the plg_entity_fields reference the V1 rules migrated onto.
|
|
341
|
-
SELECT app.masked_view_refresh('public.people', 'person', 'person');
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- 135_distribution_polarity.sql — distribution gets a subtractive form, so
|
|
3
|
-
-- "19 of 20 branches" stops costing 19 rows (#223).
|
|
4
|
-
--
|
|
5
|
-
-- `resource_grants` had only `available_in`, which is additive. Releasing a
|
|
6
|
-
-- service to 19 of 20 branches meant 19 tuples; there was no way to say
|
|
7
|
-
-- "everywhere except these two". And `products.unit_id` does not help — it holds
|
|
8
|
-
-- ONE value, so it covers exactly 1 of 20 and does nothing at 2.
|
|
9
|
-
--
|
|
10
|
-
-- an 'unavailable_in' tuple that applies to me → no
|
|
11
|
-
-- else, any 'available_in' tuple exists → only if one names a unit of mine
|
|
12
|
-
-- else → yes (default open, unchanged)
|
|
13
|
-
--
|
|
14
|
-
-- Cost becomes min(k, n−k): 1 of 20 is one row, 19 of 20 is one row.
|
|
15
|
-
--
|
|
16
|
-
-- BACKWARD COMPATIBLE BY CONSTRUCTION: with no `unavailable_in` tuples anywhere,
|
|
17
|
-
-- `excluded_in_my_units` is false and the expression reduces to 051's original.
|
|
18
|
-
-- No policy is regenerated either — the six RESTRICTIVE catalog policies call
|
|
19
|
-
-- `available_in_my_units`, so replacing the function is the whole change.
|
|
20
|
-
--
|
|
21
|
-
-- DENY IS "EVERY UNIT OF MINE", NOT "ANY"
|
|
22
|
-
--
|
|
23
|
-
-- RLS hides a row from the PERSON, not from a unit. A manager who holds b1 and
|
|
24
|
-
-- b2, with b2 excluded, must still see the row — she has a branch that offers
|
|
25
|
-
-- it. "Any" would hide it from her entirely, which is a different and wrong
|
|
26
|
-
-- statement. So the unit-subject exclusion only bites when no unit of hers
|
|
27
|
-
-- escapes it.
|
|
28
|
-
--
|
|
29
|
-
-- …and "of mine" respects the session's focus (#220). Focused on b2, b2 is the
|
|
30
|
-
-- only unit in scope, so an exclusion on b2 does hide the row — which is the
|
|
31
|
-
-- answer the person actually asked for. The two features compose: the focus is
|
|
32
|
-
-- what makes a per-unit exclusion legible at all.
|
|
33
|
-
--
|
|
34
|
-
-- A user-subject or role-subject exclusion has none of that subtlety: the
|
|
35
|
-
-- subject IS the person, so it simply bites.
|
|
36
|
-
--
|
|
37
|
-
-- Replay-safe: CREATE OR REPLACE only.
|
|
38
|
-
-- ============================================================================
|
|
39
|
-
|
|
40
|
-
-- ── excluded_in_my_units(type, id) ──────────────────────────────────────
|
|
41
|
-
CREATE OR REPLACE FUNCTION app.excluded_in_my_units(p_type text, p_id uuid)
|
|
42
|
-
RETURNS boolean
|
|
43
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
44
|
-
SET search_path = ''
|
|
45
|
-
AS $$
|
|
46
|
-
WITH me AS (
|
|
47
|
-
SELECT app.current_tenant_id() AS tenant_id,
|
|
48
|
-
auth.uid() AS uid,
|
|
49
|
-
nullif(app.current_unit_id(), '00000000-0000-0000-0000-000000000000'::uuid) AS focus
|
|
50
|
-
),
|
|
51
|
-
ex AS (
|
|
52
|
-
SELECT g.subject_type, g.subject_id
|
|
53
|
-
FROM app.resource_grants g, me
|
|
54
|
-
WHERE g.tenant_id = me.tenant_id
|
|
55
|
-
AND g.resource_type = p_type
|
|
56
|
-
AND g.resource_id = p_id
|
|
57
|
-
AND g.relation = 'unavailable_in'
|
|
58
|
-
),
|
|
59
|
-
-- the units in scope right now: what I hold, narrowed by the focus
|
|
60
|
-
mine AS (
|
|
61
|
-
SELECT a.unit_id
|
|
62
|
-
FROM app.user_unit_access a, me
|
|
63
|
-
WHERE a.tenant_id = me.tenant_id
|
|
64
|
-
AND a.user_id = me.uid
|
|
65
|
-
AND (me.focus IS NULL OR a.unit_id = me.focus)
|
|
66
|
-
)
|
|
67
|
-
SELECT
|
|
68
|
-
-- named directly: me, or a role I hold. No "every" about it.
|
|
69
|
-
EXISTS (
|
|
70
|
-
SELECT 1 FROM ex, me
|
|
71
|
-
WHERE (ex.subject_type = 'user' AND ex.subject_id = me.uid)
|
|
72
|
-
OR (ex.subject_type = 'role' AND ex.subject_id IN (
|
|
73
|
-
SELECT m.role_id FROM app.memberships m
|
|
74
|
-
WHERE m.tenant_id = me.tenant_id AND m.user_id = me.uid AND m.active
|
|
75
|
-
UNION
|
|
76
|
-
SELECT mu.role_override_id FROM app.membership_units mu
|
|
77
|
-
WHERE mu.tenant_id = me.tenant_id AND mu.user_id = me.uid AND mu.role_override_id IS NOT NULL)))
|
|
78
|
-
-- or every unit in scope is excluded — and there is at least one, so that a
|
|
79
|
-
-- caller who holds no units at all does not lose the catalogue to a rule
|
|
80
|
-
-- about units that cannot apply to them.
|
|
81
|
-
OR (
|
|
82
|
-
EXISTS (SELECT 1 FROM ex WHERE ex.subject_type = 'unit')
|
|
83
|
-
AND EXISTS (SELECT 1 FROM mine)
|
|
84
|
-
AND NOT EXISTS (
|
|
85
|
-
SELECT 1 FROM mine
|
|
86
|
-
WHERE NOT EXISTS (SELECT 1 FROM ex WHERE ex.subject_type = 'unit' AND ex.subject_id = mine.unit_id))
|
|
87
|
-
);
|
|
88
|
-
$$;
|
|
89
|
-
REVOKE ALL ON FUNCTION app.excluded_in_my_units(text, uuid) FROM public, anon;
|
|
90
|
-
GRANT EXECUTE ON FUNCTION app.excluded_in_my_units(text, uuid) TO authenticated, service_role;
|
|
91
|
-
|
|
92
|
-
COMMENT ON FUNCTION app.excluded_in_my_units(text, uuid) IS
|
|
93
|
-
'Does an `unavailable_in` tuple hide this resource from the caller (#223)? A user- or role-subject tuple bites directly. A unit-subject tuple bites only when EVERY unit in scope is excluded — RLS hides a row from the person, not from a unit — and "in scope" is narrowed by the session''s operating unit (#220).';
|
|
94
|
-
|
|
95
|
-
-- ── has_grant(type, id, relations[]) — the unit subject respects the focus ──
|
|
96
|
-
-- Found by the test for "released to b1, focused on b2": the row stayed visible,
|
|
97
|
-
-- because 051's has_grant matches a unit-subject tuple against the caller's WHOLE
|
|
98
|
-
-- user_unit_access, and b1 is still hers even while she is operating in b2.
|
|
99
|
-
--
|
|
100
|
-
-- That is the same asymmetry the exclusion side had to solve, so it is solved in
|
|
101
|
-
-- the same place rather than worked around in the caller. The focus narrows what
|
|
102
|
-
-- "a unit of mine" means, for every relation and every caller — `available_in`
|
|
103
|
-
-- here, and `shared_with` in the owner clause of all 45 scaffolded tables. Both
|
|
104
|
-
-- readings are consistent: while operating in b2, a release to b1 is not offered
|
|
105
|
-
-- here, and a share with b1's staff is not mine to use here.
|
|
106
|
-
--
|
|
107
|
-
-- User- and role-subject tuples are untouched: the subject is the person, and a
|
|
108
|
-
-- person does not change branch.
|
|
109
|
-
CREATE OR REPLACE FUNCTION app.has_grant(p_type text, p_id uuid, p_relations text[])
|
|
110
|
-
RETURNS boolean
|
|
111
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
112
|
-
SET search_path = ''
|
|
113
|
-
AS $$
|
|
114
|
-
SELECT EXISTS (
|
|
115
|
-
SELECT 1
|
|
116
|
-
FROM app.resource_grants g
|
|
117
|
-
WHERE g.tenant_id = app.current_tenant_id()
|
|
118
|
-
AND g.resource_type = p_type
|
|
119
|
-
AND g.resource_id = p_id
|
|
120
|
-
AND g.relation = ANY (p_relations)
|
|
121
|
-
AND (
|
|
122
|
-
(g.subject_type = 'user' AND g.subject_id = auth.uid())
|
|
123
|
-
OR (g.subject_type = 'unit' AND EXISTS (
|
|
124
|
-
SELECT 1 FROM app.user_unit_access a
|
|
125
|
-
WHERE a.tenant_id = g.tenant_id AND a.user_id = auth.uid() AND a.unit_id = g.subject_id
|
|
126
|
-
AND (app.current_unit_id() IS NULL OR a.unit_id = app.current_unit_id())))
|
|
127
|
-
OR (g.subject_type = 'role' AND g.subject_id IN (
|
|
128
|
-
SELECT m.role_id FROM app.memberships m
|
|
129
|
-
WHERE m.tenant_id = g.tenant_id AND m.user_id = auth.uid() AND m.active
|
|
130
|
-
UNION
|
|
131
|
-
SELECT mu.role_override_id FROM app.membership_units mu
|
|
132
|
-
WHERE mu.tenant_id = g.tenant_id AND mu.user_id = auth.uid() AND mu.role_override_id IS NOT NULL))
|
|
133
|
-
)
|
|
134
|
-
);
|
|
135
|
-
$$;
|
|
136
|
-
|
|
137
|
-
COMMENT ON FUNCTION app.has_grant(text, uuid, text[]) IS
|
|
138
|
-
'resource_grants where the subject is me, a role of mine, or a unit in scope — "in scope" being narrowed by the session''s operating unit (#220/#223). User and role subjects ignore the focus: the subject is the person.';
|
|
139
|
-
|
|
140
|
-
-- ── available_in_my_units(type, id) ─────────────────────────────────────
|
|
141
|
-
-- 051's expression with the exclusion ANDed in front. Deny wins: an explicit
|
|
142
|
-
-- exclusion beats an explicit inclusion, which is the precedence every access
|
|
143
|
-
-- system settles on and the only one that is safe to get wrong in one direction.
|
|
144
|
-
CREATE OR REPLACE FUNCTION app.available_in_my_units(p_type text, p_id uuid)
|
|
145
|
-
RETURNS boolean
|
|
146
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
147
|
-
SET search_path = ''
|
|
148
|
-
AS $$
|
|
149
|
-
SELECT NOT app.excluded_in_my_units(p_type, p_id)
|
|
150
|
-
AND (NOT EXISTS (
|
|
151
|
-
SELECT 1 FROM app.resource_grants g
|
|
152
|
-
WHERE g.tenant_id = app.current_tenant_id()
|
|
153
|
-
AND g.resource_type = p_type AND g.resource_id = p_id AND g.relation = 'available_in')
|
|
154
|
-
OR app.has_grant(p_type, p_id, ARRAY['available_in']));
|
|
155
|
-
$$;
|
|
156
|
-
|
|
157
|
-
COMMENT ON FUNCTION app.available_in_my_units(text, uuid) IS
|
|
158
|
-
'The release rule with polarity (#223): an applicable `unavailable_in` tuple hides it; otherwise no `available_in` tuple ⇒ available everywhere, and one ⇒ only where it names. Cost is min(k, n−k) — 1 of 20 and 19 of 20 are both one row.';
|