From 43240821f24c829ecdbaee6ebebf2bf3de0e89f4 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Tue, 18 Aug 2026 16:57:30 +1000 Subject: [PATCH 1/6] feat: PPT-526 add Partner->Client hierarchy and client ownership columns The first persisted layer of the Integrator/Partner -> Client -> Domain hierarchy: - partners (uuidv7; management flag for the platform operator's own org; nullable self-parent reserving a 2-tier channel) and clients (nullable partner_id, NULL = client-owned; payer flag records who is invoiced, partner-pays default). All delete paths RESTRICT - teardown must be orchestrated, never a silent DB cascade. - authority.client_id plus client_id on zone/sys/mod/trigger/edge/broker (nullable; ownership ground truth for the query enforcement that follows). mass_assignment: false everywhere - ownership is not writable via untrusted payloads. - authority.domain gains a real UNIQUE index (self-signup depends on it), preceded by a quarantine step that renames legacy duplicate domains so the index always builds. - dormant partial UNIQUE (client_id, name) indexes on zone/sys/edge/broker, activated per-row as ownership is assigned. - idempotent backfill: management partner, one client-owned Client per authority, estate inference via org_zone resolved per zone TREE (contested or subtree-only claims left NULL for a human), collision-safe against the new unique indexes. Verified: full spec suite green (660 examples; the one error is the known flaky user_spec concurrency test, passes in isolation); backfill exercised against realistic + adversarial fixtures via tasks/PPT-526/backfill-verify.sh (24 assertions incl. duplicate names, contested trees, operator-created partner rows, domain quarantine); ameba clean. --- ...0818100500000_add_partners_and_clients.sql | 211 +++++++++++++++++ ...818100600000_backfill_client_ownership.sql | 222 ++++++++++++++++++ spec/client_spec.cr | 104 ++++++++ spec/generator.cr | 16 ++ spec/helper.cr | 2 + spec/partner_spec.cr | 70 ++++++ src/placeos-models/authority.cr | 9 + src/placeos-models/broker.cr | 10 + src/placeos-models/client.cr | 71 ++++++ src/placeos-models/control_system.cr | 9 + src/placeos-models/edge.cr | 9 + src/placeos-models/module.cr | 11 + src/placeos-models/partner.cr | 72 ++++++ src/placeos-models/trigger.cr | 10 + src/placeos-models/zone.cr | 9 + 15 files changed, 835 insertions(+) create mode 100644 migration/db/migrations/20260818100500000_add_partners_and_clients.sql create mode 100644 migration/db/migrations/20260818100600000_backfill_client_ownership.sql create mode 100644 spec/client_spec.cr create mode 100644 spec/partner_spec.cr create mode 100644 src/placeos-models/client.cr create mode 100644 src/placeos-models/partner.cr diff --git a/migration/db/migrations/20260818100500000_add_partners_and_clients.sql b/migration/db/migrations/20260818100500000_add_partners_and_clients.sql new file mode 100644 index 00000000..59478ca6 --- /dev/null +++ b/migration/db/migrations/20260818100500000_add_partners_and_clients.sql @@ -0,0 +1,211 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- MERGE-TIME CHECK: micrate refuses out-of-order version ids (see models#323). +-- If any later-dated migration lands on master before this merges, renumber +-- this file AND 20260818100600000 above it (preserving schema-before-backfill +-- order). Safe: neither has been applied anywhere and the backfill is +-- idempotent. + +-- --------------------------------------------------------------------------- +-- PPT-526: the Partner → Client → Authority(domain) hierarchy. +-- +-- partners: integrators/resellers (e.g. NTT). `management = true` marks the +-- platform operator's own organisation (PlaceOS staff) — the "management +-- client" concept from the ticket, done at the partner level. `parent_id` +-- reserves a 2-tier channel (distributor → reseller) without any UI or +-- enforcement yet. +-- +-- clients: the customer organisation (e.g. UCLA, Acadian). `partner_id` NULL +-- means client-owned (self-managed, no integrator). `payer` records who is +-- invoiced: 'partner' (integrator pays us and re-bills, the default channel +-- model) or 'client' (direct). A client without a partner can only pay for +-- itself — enforced by CHECK. +-- +-- Every delete path is RESTRICT: removing a partner/client must go through an +-- orchestrated teardown (the PPT-1203 pattern), never a silent DB cascade — +-- model-level cleanup callbacks do not fire on DB cascades. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "partners"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + management BOOLEAN NOT NULL DEFAULT false, + parent_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS partners_name_unique + ON "partners" USING BTREE (name); +CREATE INDEX IF NOT EXISTS partners_parent_id_index + ON "partners" USING BTREE (parent_id); + +CREATE TABLE IF NOT EXISTS "clients"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + partner_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + payer TEXT NOT NULL DEFAULT 'partner' + CHECK (payer IN ('partner', 'client')), + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT clients_payer_requires_partner + CHECK (partner_id IS NOT NULL OR payer = 'client') +); + +-- Client names are unique within a partner's book of business, and unique +-- among client-owned clients. Two partial indexes because UNIQUE treats +-- NULLs as distinct — a plain UNIQUE (partner_id, name) would let every +-- client-owned client share a name. +CREATE UNIQUE INDEX IF NOT EXISTS clients_name_unique_per_partner + ON "clients" USING BTREE (partner_id, name) WHERE partner_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS clients_name_unique_owned + ON "clients" USING BTREE (name) WHERE partner_id IS NULL; +CREATE INDEX IF NOT EXISTS clients_partner_id_index + ON "clients" USING BTREE (partner_id); + +-- --------------------------------------------------------------------------- +-- Authority → Client ownership. Nullable at the DB level for backwards +-- compatibility (init seeds authorities before any client exists); the +-- provisioning flow and backfill populate it. RESTRICT so a client cannot be +-- deleted while it still owns domains. +-- --------------------------------------------------------------------------- +ALTER TABLE "authority" + ADD COLUMN IF NOT EXISTS client_id UUID; + +ALTER TABLE ONLY "authority" + DROP CONSTRAINT IF EXISTS authority_client_id_fkey; +ALTER TABLE ONLY "authority" + ADD CONSTRAINT authority_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS authority_client_id_index + ON "authority" USING BTREE (client_id); + +-- The domain column has only ever had model-level uniqueness (a plain BTREE +-- index) — a concurrent-signup race can create duplicate authorities. Make it +-- a hard guarantee; self-service signup depends on it. +-- +-- Quarantine first: exact-duplicate domains left behind by that race would +-- make the unique index unbuildable and (because micrate autocommits each +-- statement) leave this migration half-applied. Keep the oldest row per +-- domain and rename the rest so they stop answering for the domain — +-- find_by_domain picks an arbitrary row across duplicates today, so this +-- cannot break a reliably-working login. Idempotent: renamed rows no longer +-- collide. Renamed rows are for an operator to merge or delete. +UPDATE "authority" a +SET domain = a.domain || '.duplicate.' || a.id +WHERE EXISTS ( + SELECT 1 FROM "authority" b + WHERE b.domain = a.domain + AND (b.created_at, b.id) < (a.created_at, a.id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS authority_domain_unique + ON "authority" USING BTREE (domain); + +-- --------------------------------------------------------------------------- +-- Client ownership columns on the (previously cluster-global) control plane. +-- Nullable: populated by the backfill migration where inference is +-- unambiguous, and by the provisioning flow for everything new. Query-path +-- enforcement is phased in separately (rest-api); these columns are the +-- ground truth it will filter on. +-- --------------------------------------------------------------------------- +ALTER TABLE "zone" ADD COLUMN IF NOT EXISTS client_id UUID; +ALTER TABLE "sys" ADD COLUMN IF NOT EXISTS client_id UUID; +ALTER TABLE "mod" ADD COLUMN IF NOT EXISTS client_id UUID; +ALTER TABLE "trigger" ADD COLUMN IF NOT EXISTS client_id UUID; +ALTER TABLE "edge" ADD COLUMN IF NOT EXISTS client_id UUID; +ALTER TABLE "broker" ADD COLUMN IF NOT EXISTS client_id UUID; + +ALTER TABLE ONLY "zone" + DROP CONSTRAINT IF EXISTS zone_client_id_fkey; +ALTER TABLE ONLY "zone" + ADD CONSTRAINT zone_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "sys" + DROP CONSTRAINT IF EXISTS sys_client_id_fkey; +ALTER TABLE ONLY "sys" + ADD CONSTRAINT sys_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "mod" + DROP CONSTRAINT IF EXISTS mod_client_id_fkey; +ALTER TABLE ONLY "mod" + ADD CONSTRAINT mod_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "trigger" + DROP CONSTRAINT IF EXISTS trigger_client_id_fkey; +ALTER TABLE ONLY "trigger" + ADD CONSTRAINT trigger_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "edge" + DROP CONSTRAINT IF EXISTS edge_client_id_fkey; +ALTER TABLE ONLY "edge" + ADD CONSTRAINT edge_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "broker" + DROP CONSTRAINT IF EXISTS broker_client_id_fkey; +ALTER TABLE ONLY "broker" + ADD CONSTRAINT broker_client_id_fkey + FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS zone_client_id_index ON "zone" USING BTREE (client_id); +CREATE INDEX IF NOT EXISTS sys_client_id_index ON "sys" USING BTREE (client_id); +CREATE INDEX IF NOT EXISTS mod_client_id_index ON "mod" USING BTREE (client_id); +CREATE INDEX IF NOT EXISTS trigger_client_id_index ON "trigger" USING BTREE (client_id); +CREATE INDEX IF NOT EXISTS edge_client_id_index ON "edge" USING BTREE (client_id); +CREATE INDEX IF NOT EXISTS broker_client_id_index ON "broker" USING BTREE (client_id); + +-- Per-client name uniqueness for the estate. Dormant while client_id is NULL +-- (UNIQUE treats NULLs as distinct) and strictly weaker than the current +-- model-level global uniqueness, so it cannot conflict with existing data. +-- When query enforcement lands, the model-level checks flip from global to +-- client-scoped and these indexes become the hard guarantee. +CREATE UNIQUE INDEX IF NOT EXISTS zone_client_id_name_unique + ON "zone" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS sys_client_id_name_unique + ON "sys" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS edge_client_id_name_unique + ON "edge" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS broker_client_id_name_unique + ON "broker" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP INDEX IF EXISTS broker_client_id_name_unique; +DROP INDEX IF EXISTS edge_client_id_name_unique; +DROP INDEX IF EXISTS sys_client_id_name_unique; +DROP INDEX IF EXISTS zone_client_id_name_unique; + +DROP INDEX IF EXISTS broker_client_id_index; +DROP INDEX IF EXISTS edge_client_id_index; +DROP INDEX IF EXISTS trigger_client_id_index; +DROP INDEX IF EXISTS mod_client_id_index; +DROP INDEX IF EXISTS sys_client_id_index; +DROP INDEX IF EXISTS zone_client_id_index; + +ALTER TABLE ONLY "broker" DROP CONSTRAINT IF EXISTS broker_client_id_fkey; +ALTER TABLE ONLY "edge" DROP CONSTRAINT IF EXISTS edge_client_id_fkey; +ALTER TABLE ONLY "trigger" DROP CONSTRAINT IF EXISTS trigger_client_id_fkey; +ALTER TABLE ONLY "mod" DROP CONSTRAINT IF EXISTS mod_client_id_fkey; +ALTER TABLE ONLY "sys" DROP CONSTRAINT IF EXISTS sys_client_id_fkey; +ALTER TABLE ONLY "zone" DROP CONSTRAINT IF EXISTS zone_client_id_fkey; + +ALTER TABLE "broker" DROP COLUMN IF EXISTS client_id; +ALTER TABLE "edge" DROP COLUMN IF EXISTS client_id; +ALTER TABLE "trigger" DROP COLUMN IF EXISTS client_id; +ALTER TABLE "mod" DROP COLUMN IF EXISTS client_id; +ALTER TABLE "sys" DROP COLUMN IF EXISTS client_id; +ALTER TABLE "zone" DROP COLUMN IF EXISTS client_id; + +DROP INDEX IF EXISTS authority_domain_unique; +DROP INDEX IF EXISTS authority_client_id_index; +ALTER TABLE ONLY "authority" DROP CONSTRAINT IF EXISTS authority_client_id_fkey; +ALTER TABLE "authority" DROP COLUMN IF EXISTS client_id; + +DROP TABLE IF EXISTS "clients"; +DROP TABLE IF EXISTS "partners"; diff --git a/migration/db/migrations/20260818100600000_backfill_client_ownership.sql b/migration/db/migrations/20260818100600000_backfill_client_ownership.sql new file mode 100644 index 00000000..f26b266b --- /dev/null +++ b/migration/db/migrations/20260818100600000_backfill_client_ownership.sql @@ -0,0 +1,222 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- --------------------------------------------------------------------------- +-- PPT-526: backfill Partner/Client ownership for existing data. +-- +-- Idempotent and additive: every statement only creates missing rows or fills +-- NULL client_id columns, so it is safe to re-apply and safe on databases +-- where operators have already assigned ownership by hand. Where inference is +-- ambiguous the row is deliberately LEFT NULL for a human to resolve (the +-- org_zone convention is known to be non-exclusive and sometimes absent). +-- +-- What it does: +-- 1. Ensures a management partner exists ("PlaceOS" — the platform +-- operator's own organisation). +-- 2. Creates one client-owned Client per Authority that has none, named +-- " ()" (domain uniqueness makes this collision +-- free), and points the authority at it. Estates that span several +-- authorities under one real-world client (e.g. NTT's two domains) are +-- merged later by re-pointing authority.client_id by hand or API. +-- 3. Zones: a zone belongs to the client of the authority whose +-- config->>'org_zone' names the zone's ROOT zone — only when exactly one +-- client is implied (shared org zones stay NULL). +-- 4. Systems: the single distinct client of their zones, if unambiguous. +-- 5. Modules: logic modules via their control system; shared device/service +-- modules via the systems that reference them, if unambiguous. +-- 6. Trigger definitions: via their control system when system-scoped; +-- unscoped trigger definitions stay NULL (shared library semantics). +-- 7. Edges: via their bound user's authority. +-- Brokers are deliberately NOT backfilled (cluster-level MQTT infra). +-- --------------------------------------------------------------------------- + +-- 1. Management partner. ON CONFLICT: if an operator already created a +-- non-management partner named 'PlaceOS', leave it for a human rather +-- than promote it (this file only creates missing rows / fills NULLs). +INSERT INTO "partners" (name, description, management, created_at, updated_at) +SELECT 'PlaceOS', 'Platform operator (management partner)', true, now(), now() +WHERE NOT EXISTS (SELECT 1 FROM "partners" WHERE management = true) +ON CONFLICT DO NOTHING; + +-- 2. One client-owned Client per orphan Authority +-- +micrate StatementBegin +DO $$ +DECLARE + auth RECORD; + new_client UUID; +BEGIN + FOR auth IN SELECT id, name, domain FROM "authority" WHERE client_id IS NULL LOOP + new_client := NULL; + INSERT INTO "clients" (name, description, partner_id, payer, created_at, updated_at) + VALUES ( + COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')', + 'Auto-created from authority ' || auth.id || ' during PPT-526 client backfill', + NULL, 'client', now(), now() + ) + ON CONFLICT DO NOTHING + RETURNING id INTO new_client; + + -- If the name already existed (re-run against a partially-backfilled DB), + -- reuse the existing row rather than leaving the authority orphaned. + IF new_client IS NULL THEN + SELECT id INTO new_client FROM "clients" + WHERE partner_id IS NULL + AND name = COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')'; + END IF; + + IF new_client IS NOT NULL THEN + UPDATE "authority" SET client_id = new_client WHERE id = auth.id; + END IF; + END LOOP; +END $$; +-- +micrate StatementEnd + +-- 3. Zones via org_zone → root-zone walk (unambiguous owners only). +-- UNION (not UNION ALL) so accidental cycles in zone parentage terminate. +-- Ambiguity is judged per TREE, not per org_zone string: every authority's +-- org_zone claim is resolved to its tree root, a tree claimed by more than +-- one distinct client is vetoed, and ownership is only assigned when at +-- least one claim names the root itself (a claim on a sub-zone alone must +-- not annex the whole tree). Rows whose (client_id, name) would collide +-- with the partial unique indexes are skipped (left NULL for a human) — +-- duplicate names within one estate are legacy race artifacts. +-- +micrate StatementBegin +WITH RECURSIVE zone_roots AS ( + SELECT id, id AS root_id + FROM "zone" + WHERE parent_id IS NULL OR parent_id = '' + UNION + SELECT z.id, r.root_id + FROM "zone" z + INNER JOIN zone_roots r ON z.parent_id = r.id +), +tree_claims AS ( + SELECT r.root_id, + (a.config->>'org_zone' = r.root_id) AS names_root, + a.client_id + FROM "authority" a + INNER JOIN zone_roots r ON r.id = a.config->>'org_zone' + WHERE COALESCE(a.config->>'org_zone', '') <> '' + AND a.client_id IS NOT NULL +), +org_owner AS ( + SELECT root_id, MIN(client_id::text)::uuid AS client_id + FROM tree_claims + GROUP BY root_id + HAVING COUNT(DISTINCT client_id) = 1 + AND bool_or(names_root) +), +candidates AS ( + SELECT z.id, o.client_id, z.name, + ROW_NUMBER() OVER ( + PARTITION BY o.client_id, z.name + ORDER BY z.created_at, z.id + ) AS rn + FROM "zone" z + INNER JOIN zone_roots r ON z.id = r.id + INNER JOIN org_owner o ON r.root_id = o.root_id + WHERE z.client_id IS NULL +) +UPDATE "zone" z +SET client_id = c.client_id +FROM candidates c +WHERE z.id = c.id + AND c.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM "zone" x + WHERE x.client_id = c.client_id AND x.name = z.name AND x.id <> z.id + ); +-- +micrate StatementEnd + +-- 4. Systems: single distinct client across their zones. Same collision +-- skip as zones (sys carries a (client_id, name) partial unique index). +-- +micrate StatementBegin +WITH sys_candidates AS ( + SELECT s2.id AS sys_id, s2.name, + MIN(z.client_id::text)::uuid AS client_id + FROM "sys" s2 + INNER JOIN "zone" z ON z.id = ANY(s2.zones) + WHERE z.client_id IS NOT NULL + AND s2.client_id IS NULL + GROUP BY s2.id, s2.name + HAVING COUNT(DISTINCT z.client_id) = 1 +), +ranked AS ( + SELECT sys_id, name, client_id, + ROW_NUMBER() OVER ( + PARTITION BY client_id, name + ORDER BY sys_id + ) AS rn + FROM sys_candidates +) +UPDATE "sys" s +SET client_id = r.client_id +FROM ranked r +WHERE s.id = r.sys_id + AND r.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM "sys" x + WHERE x.client_id = r.client_id AND x.name = s.name AND x.id <> s.id + ); +-- +micrate StatementEnd + +-- 5a. Logic modules via their control system +UPDATE "mod" m +SET client_id = s.client_id +FROM "sys" s +WHERE m.control_system_id = s.id + AND s.client_id IS NOT NULL + AND m.client_id IS NULL; + +-- 5b. Device/service modules via the systems that reference them +UPDATE "mod" m +SET client_id = sc.client_id +FROM ( + SELECT m2.id AS mod_id, MIN(s.client_id::text)::uuid AS client_id + FROM "mod" m2 + INNER JOIN "sys" s ON m2.id = ANY(s.modules) + WHERE s.client_id IS NOT NULL + GROUP BY m2.id + HAVING COUNT(DISTINCT s.client_id) = 1 +) sc +WHERE m.id = sc.mod_id AND m.client_id IS NULL; + +-- 6. System-scoped trigger definitions +UPDATE "trigger" t +SET client_id = s.client_id +FROM "sys" s +WHERE t.control_system_id = s.id + AND s.client_id IS NOT NULL + AND t.client_id IS NULL; + +-- 7. Edges via their bound user's authority. Same collision skip (edge +-- carries a (client_id, name) partial unique index). +-- +micrate StatementBegin +WITH edge_candidates AS ( + SELECT e.id, e.name, a.client_id, + ROW_NUMBER() OVER ( + PARTITION BY a.client_id, e.name + ORDER BY e.id + ) AS rn + FROM "edge" e + INNER JOIN "user" u ON e.user_id = u.id + INNER JOIN "authority" a ON u.authority_id = a.id + WHERE a.client_id IS NOT NULL + AND e.client_id IS NULL +) +UPDATE "edge" e +SET client_id = c.client_id +FROM edge_candidates c +WHERE e.id = c.id + AND c.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM "edge" x + WHERE x.client_id = c.client_id AND x.name = e.name AND x.id <> e.id + ); +-- +micrate StatementEnd + +-- +micrate Down +-- Deliberate no-op: the backfill only fills NULLs and creates rows that +-- operators may since have adopted; unwinding it automatically could destroy +-- hand-made ownership assignments. Rolling back the schema migration +-- (20260818100500000) removes the columns and tables wholesale. diff --git a/spec/client_spec.cr b/spec/client_spec.cr new file mode 100644 index 00000000..e0f8bb36 --- /dev/null +++ b/spec/client_spec.cr @@ -0,0 +1,104 @@ +require "./helper" + +module PlaceOS::Model + describe Client do + Spec.before_each do + ControlSystem.clear + Zone.clear + Authority.clear + Client.clear + Partner.clear + end + + it "saves a partnered client with partner-pays default" do + partner = Generator.partner.save! + client = Generator.client(partner: partner).save! + client.persisted?.should be_true + client.payer.should eq Client::PAYER_PARTNER + client.client_owned?.should be_false + client.partner.try(&.id).should eq partner.id + end + + it "normalizes a client-owned client to pay for itself" do + client = Generator.client.save! + client.partner_id.should be_nil + client.payer.should eq Client::PAYER_CLIENT + client.client_owned?.should be_true + end + + it "rejects an unknown payer" do + client = Generator.client(partner: Generator.partner.save!, payer: "nobody") + client.valid?.should be_false + client.errors.map(&.field).should contain(:payer) + end + + it "scopes client name uniqueness to the partner" do + partner_a = Generator.partner.save! + partner_b = Generator.partner.save! + original = Generator.client(partner: partner_a).save! + + # Same name under a different partner is fine + sibling = Generator.client(partner: partner_b) + sibling.name = original.name + sibling.valid?.should be_true + + # Same name under the same partner is not + duplicate = Generator.client(partner: partner_a) + duplicate.name = original.name + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + end + + it "rejects duplicate names among client-owned clients" do + original = Generator.client.save! + duplicate = Generator.client + duplicate.name = original.name + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + end + + it "owns authorities via authority.client_id" do + client = Generator.client.save! + authority = Generator.authority(domain: "client-spec.example.com") + authority.client_id = client.id + authority.save! + + authority.client.try(&.id).should eq client.id + client.authorities.to_a.map(&.id).should eq [authority.id] + end + + it "refuses to delete a client that still owns a domain" do + client = Generator.client.save! + authority = Generator.authority(domain: "client-restrict.example.com") + authority.client_id = client.id + authority.save! + + expect_raises(Exception, /foreign key/) { client.destroy } + Client.find?(client.id.not_nil!).should_not be_nil + end + + it "rejects renaming a client onto a sibling's name" do + partner = Generator.partner.save! + original = Generator.client(partner: partner).save! + sibling = Generator.client(partner: partner).save! + + sibling.name = original.name + sibling.valid?.should be_false + sibling.errors.map(&.field).should contain(:name) + end + + it "records client ownership on zones and systems" do + client = Generator.client.save! + zone = Generator.zone + zone.client_id = client.id + zone.save! + + sys = Generator.control_system + sys.client_id = client.id + sys.save! + + Zone.find!(zone.id.not_nil!).client.try(&.id).should eq client.id + ControlSystem.find!(sys.id.not_nil!).client.try(&.id).should eq client.id + end + end +end diff --git a/spec/generator.cr b/spec/generator.cr index cfea5adc..1ce8c079 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -565,6 +565,22 @@ module PlaceOS::Model ) end + def self.partner(management : Bool = false, parent : Partner? = nil) + Partner.new( + name: Faker::Hacker.noun + "-" + RANDOM.hex(3), + management: management, + parent_id: parent.try(&.id), + ) + end + + def self.client(partner : Partner? = nil, payer : String = Client::PAYER_PARTNER) + Client.new( + name: Faker::Hacker.noun + "-" + RANDOM.hex(3), + partner_id: partner.try(&.id), + payer: payer, + ) + end + def self.user(authority : Authority? = nil, support : Bool = false, admin : Bool = false) unless authority # look up an existing authority diff --git a/spec/helper.cr b/spec/helper.cr index def4d5e6..e4a76705 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -39,6 +39,8 @@ Spec.after_suite do PlaceOS::Model::GroupPlaylistItem, PlaceOS::Model::GroupPlaylist, PlaceOS::Model::Group, + PlaceOS::Model::Client, + PlaceOS::Model::Partner, ].each(&.clear) end diff --git a/spec/partner_spec.cr b/spec/partner_spec.cr new file mode 100644 index 00000000..914502e2 --- /dev/null +++ b/spec/partner_spec.cr @@ -0,0 +1,70 @@ +require "./helper" + +module PlaceOS::Model + describe Partner do + Spec.before_each do + ControlSystem.clear + Zone.clear + Authority.clear + Client.clear + Partner.clear + end + + it "saves a partner" do + partner = Generator.partner.save! + partner.persisted?.should be_true + partner.management.should be_false + partner.parent_id.should be_nil + end + + it "saves a management partner" do + partner = Generator.partner(management: true).save! + partner.management.should be_true + end + + it "rejects duplicate partner names" do + existing = Generator.partner.save! + duplicate = Generator.partner + duplicate.name = existing.name + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + end + + it "supports a two-tier parent chain" do + distributor = Generator.partner.save! + reseller = Generator.partner(parent: distributor).save! + reseller.parent_id.should eq distributor.id + distributor.children.to_a.map(&.id).should contain(reseller.id) + end + + it "rejects a partner as its own parent" do + partner = Generator.partner.save! + partner.parent_id = partner.id + partner.valid?.should be_false + partner.errors.map(&.field).should contain(:parent_id) + end + + it "rejects a cycle in the parent chain" do + top = Generator.partner.save! + bottom = Generator.partner(parent: top).save! + top.parent_id = bottom.id + top.valid?.should be_false + top.errors.map(&.field).should contain(:parent_id) + end + + it "lists its clients" do + partner = Generator.partner.save! + client = Generator.client(partner: partner).save! + Generator.client.save! + partner.clients.to_a.map(&.id).should eq [client.id] + end + + it "refuses to delete a partner that still has clients" do + partner = Generator.partner.save! + Generator.client(partner: partner).save! + + expect_raises(Exception, /foreign key/) { partner.destroy } + Partner.find?(partner.id.not_nil!).should_not be_nil + end + end +end diff --git a/src/placeos-models/authority.cr b/src/placeos-models/authority.cr index 7db1e6fb..44c83572 100644 --- a/src/placeos-models/authority.cr +++ b/src/placeos-models/authority.cr @@ -28,6 +28,10 @@ module PlaceOS::Model attribute email_domains : Array(String) = [] of String + # PPT-526: the Client (customer organisation) that owns this domain. + # Nullable while ownership backfill and provisioning are phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + macro finished # Ensure only the host is saved. # @@ -73,6 +77,11 @@ module PlaceOS::Model Authority.where(domain: host).first? end + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + # Locates an authority by email domain def self.find_by_email(email : String) : Authority? parts = email.split('@', 2) diff --git a/src/placeos-models/broker.cr b/src/placeos-models/broker.cr index e68459c8..0677195a 100644 --- a/src/placeos-models/broker.cr +++ b/src/placeos-models/broker.cr @@ -33,6 +33,16 @@ module PlaceOS::Model # Matches will be replaced with a hmac_256(secret, match). attribute filters : Array(String) = -> { [] of String } + # PPT-526: the Client (customer organisation) that owns this broker. + # NULL = cluster-level infrastructure (the default; the backfill never + # assigns brokers). Nullable while query enforcement is phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + # Validation ############################################################################################### diff --git a/src/placeos-models/client.cr b/src/placeos-models/client.cr new file mode 100644 index 00000000..f211c448 --- /dev/null +++ b/src/placeos-models/client.cr @@ -0,0 +1,71 @@ +require "uuid" +require "uuid/json" + +require "./base/model" +require "./partner" + +module PlaceOS::Model + # A customer organisation (e.g. UCLA, Acadian) — the estate owner in the + # PPT-526 hierarchy: Partner -> Client -> Authority (domain) -> ... + # + # `partner_id` NULL means client-owned: the customer signed up and manages + # itself without an integrator. `payer` records who receives invoices for + # this client: the partner (integrator pays us and re-bills — the default + # channel model) or the client directly. A client without a partner can + # only pay for itself; `normalize_payer` keeps that invariant ahead of the + # DB CHECK constraint. + class Client < ::PgORM::Base + include PgORM::Timestamps + + table :clients + + PAYER_PARTNER = "partner" + PAYER_CLIENT = "client" + PAYERS = [PAYER_PARTNER, PAYER_CLIENT] + + default_primary_key id : UUID, autogenerated: true + + attribute name : String, sanitize: :text + attribute description : String = "", sanitize: :common + attribute payer : String = PAYER_PARTNER, mass_assignment: false + attribute config : Hash(String, JSON::Any) = {} of String => JSON::Any + + attribute partner_id : UUID? + belongs_to :partner, class_name: Partner, foreign_key: partner_id + + before_save :normalize_payer + + validates :name, presence: true + + validate ->(this : Client) { + unless PAYERS.includes?(this.payer) + this.validation_error(:payer, "must be one of #{PAYERS.join(", ")}") + end + } + + validate ->(this : Client) { + # Friendlier error ahead of the DB's partial unique indexes: names are + # unique within a partner's book, and unique among client-owned clients. + name = this.name + return if name.nil? || name.empty? + existing = Client.where(partner_id: this.partner_id, name: name).first? + return if existing.nil? + return if this.persisted? && existing.id == this.id + this.validation_error(:name, "a client with this name already exists") + } + + protected def normalize_payer + self.payer = PAYER_CLIENT if self.partner_id.nil? + end + + # True when the customer manages itself without an integrator. + def client_owned? : Bool + self.partner_id.nil? + end + + # Domains (authorities) owned by this client. + def authorities + Authority.where(client_id: self.id) + end + end +end diff --git a/src/placeos-models/control_system.cr b/src/placeos-models/control_system.cr index 41db663d..a18c947c 100644 --- a/src/placeos-models/control_system.cr +++ b/src/placeos-models/control_system.cr @@ -42,6 +42,15 @@ module PlaceOS::Model # Array of security group ids for room access attribute security_groups : Array(String) = -> { [] of String } + # PPT-526: the Client (customer organisation) that owns this system. + # Nullable while ownership backfill and query enforcement are phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + attribute timezone : Time::Location?, converter: Time::Location::Converter, es_type: "text" # Provide fields for simplifying support diff --git a/src/placeos-models/edge.cr b/src/placeos-models/edge.cr index b65ce340..3909ff8d 100644 --- a/src/placeos-models/edge.cr +++ b/src/placeos-models/edge.cr @@ -20,6 +20,15 @@ module PlaceOS::Model attribute last_seen : Time?, converter: Time::EpochConverterOptional, mass_assignment: false attribute online : Bool = false, mass_assignment: false + # PPT-526: the Client (customer organisation) that owns this edge node. + # Nullable while ownership backfill and query enforcement are phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + @[JSON::Field(ignore: true)] getter x_api_key : String do self.api_key.as(ApiKey).x_api_key.as(String) diff --git a/src/placeos-models/module.cr b/src/placeos-models/module.cr index e94f7a80..2fe0c758 100644 --- a/src/placeos-models/module.cr +++ b/src/placeos-models/module.cr @@ -48,6 +48,17 @@ module PlaceOS::Model attribute ignore_connected : Bool = false attribute ignore_startstop : Bool = false + # PPT-526: the Client (customer organisation) that owns this module. + # Nullable while ownership backfill and query enforcement are phased in; + # shared device/service modules referenced by several clients' systems + # stay NULL until a sharing policy is decided. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + # Runtime Error Indicators attribute has_runtime_error : Bool = false, mass_assignment: false attribute error_timestamp : Time? = nil, converter: Time::EpochConverterOptional, type: "integer", format: "Int64", mass_assignment: false diff --git a/src/placeos-models/partner.cr b/src/placeos-models/partner.cr new file mode 100644 index 00000000..25927ae7 --- /dev/null +++ b/src/placeos-models/partner.cr @@ -0,0 +1,72 @@ +require "uuid" +require "uuid/json" + +require "./base/model" + +module PlaceOS::Model + # An integrator/reseller organisation (e.g. NTT) that brings clients onto + # the platform and may manage them. The top of the PPT-526 hierarchy: + # Partner -> Client -> Authority (domain) -> zones/systems/... + # + # `management = true` marks the platform operator's own organisation + # (PlaceOS staff): members of a management partner may be granted + # cluster-wide visibility. `parent_id` reserves a two-tier channel + # (distributor -> reseller); it carries no behaviour yet. + class Partner < ::PgORM::Base + include PgORM::Timestamps + + table :partners + + default_primary_key id : UUID, autogenerated: true + + attribute name : String, sanitize: :text + attribute description : String = "", sanitize: :common + attribute management : Bool = false, mass_assignment: false + attribute config : Hash(String, JSON::Any) = {} of String => JSON::Any + + attribute parent_id : UUID? + belongs_to :parent, class_name: Partner, foreign_key: parent_id + + validates :name, presence: true + + validate ->(this : Partner) { + # Friendlier error ahead of the DB's unique index. + name = this.name + return if name.nil? || name.empty? + existing = Partner.where(name: name).first? + return if existing.nil? + return if this.persisted? && existing.id == this.id + this.validation_error(:name, "a partner with this name already exists") + } + + validate ->(this : Partner) { + # The parent chain must not loop back to this partner. + parent_id = this.parent_id + return if parent_id.nil? + if this.persisted? && parent_id == this.id + this.validation_error(:parent_id, "a partner cannot be its own parent") + return + end + seen = Set(UUID).new + current = parent_id + while current + if this.persisted? && current == this.id + this.validation_error(:parent_id, "parent chain forms a cycle") + return + end + break unless seen.add?(current) + current = Partner.find?(current).try(&.parent_id) + end + } + + # Immediate child partners (distributor -> reseller). + def children + Partner.where(parent_id: self.id) + end + + # Clients under this partner's book of business. + def clients + Client.where(partner_id: self.id) + end + end +end diff --git a/src/placeos-models/trigger.cr b/src/placeos-models/trigger.cr index e5dab057..bee72d9c 100644 --- a/src/placeos-models/trigger.cr +++ b/src/placeos-models/trigger.cr @@ -27,6 +27,16 @@ module PlaceOS::Model METHODS = %w(GET POST PUT PATCH DELETE) attribute supported_methods : Array(String) = ["POST"] + # PPT-526: the Client (customer organisation) that owns this trigger + # definition. NULL = shared/cluster-wide definition. Nullable while + # ownership backfill and query enforcement are phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + # Association ############################################################################################### diff --git a/src/placeos-models/zone.cr b/src/placeos-models/zone.cr index 5c186f92..24124ed5 100644 --- a/src/placeos-models/zone.cr +++ b/src/placeos-models/zone.cr @@ -49,6 +49,15 @@ module PlaceOS::Model attribute images : Array(String) = [] of String attribute playlists : Array(String) = [] of String, es_type: "keyword" + # PPT-526: the Client (customer organisation) that owns this zone. + # Nullable while ownership backfill and query enforcement are phased in. + attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + + # The owning Client, when ownership has been assigned. + def client : PlaceOS::Model::Client? + self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + end + attribute place_id : String? # Association From ade7b3553b19d0af02da18cd38f86f8b9bc98eef Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 19 Aug 2026 15:03:13 +1000 Subject: [PATCH 2/6] feat: PPT-526 Stage 3 add cross-tenant authorization grants table The models foundation of the authorization layer: a Zanzibar-lite `grants` tuple that gives a user a Permissions bitmask over a Partner, Client, or Authority scope. Authorization resolves by walking UP from the touched resource (authority -> its client -> that client's partner) and OR-ing every live grant on the chain, so a single partner-scope grant covers all of that partner's clients, current and future (decided 2026-08-19). Decisions applied: reuse the existing Permissions flags enum (Int32 bitmask, same shape as group_users); partner grants auto-apply to all the partner's clients via the walk-up resolution; scope_id is polymorphic (no FK, inert when its scope is deleted); user_id FK CASCADE. Grant.resolve(user_id, authority) returns effective Permissions; for_user / for_scope back the future admin-console and audit views. The management-partner "support sees all" rule and rest-api enforcement are the enforcement layer's job, not the model's. Verified: grant_spec 11 examples green (authority/client/partner resolution, cross-partner isolation, permission OR, expiry, user-delete cascade); full models suite 671 examples 0 failures; ameba clean. expires_at is TIMESTAMPTZ mapped as a plain Time (no epoch converter). --- .../20260819100500000_add_grants.sql | 56 ++++++++ spec/generator.cr | 17 +++ spec/grant_spec.cr | 109 ++++++++++++++ spec/helper.cr | 1 + src/placeos-models/grant.cr | 135 ++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 migration/db/migrations/20260819100500000_add_grants.sql create mode 100644 spec/grant_spec.cr create mode 100644 src/placeos-models/grant.cr diff --git a/migration/db/migrations/20260819100500000_add_grants.sql b/migration/db/migrations/20260819100500000_add_grants.sql new file mode 100644 index 00000000..521c15f4 --- /dev/null +++ b/migration/db/migrations/20260819100500000_add_grants.sql @@ -0,0 +1,56 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- MERGE-TIME CHECK: micrate refuses out-of-order version ids. If a later-dated +-- migration lands on master before this merges, renumber this file above it. + +-- --------------------------------------------------------------------------- +-- PPT-526 Stage 3: the authorization `grants` table (Zanzibar-lite tuple). +-- +-- A grant gives a user a permission bitmask over a scope, which is one of a +-- Partner, a Client, or an Authority. Authorization walks UP from the touched +-- resource (authority -> its client -> that client's partner) and ORs every +-- live grant found on the chain, so a single grant at partner scope applies to +-- all of that partner's clients, current and future (decision b, 2026-08-19). +-- +-- `scope_id` is polymorphic (a partner/client UUID as text, or an authority +-- TEXT id), so it carries no DB foreign key. A grant naming a deleted scope is +-- inert: resolution walks up from live resources and never matches it, so +-- orphan grants are harmless and can be swept lazily. The one real FK is +-- user_id (CASCADE) so a user's grants vanish with the user. +-- +-- `permissions` is the existing `PlaceOS::Model::Permissions` flags enum stored +-- as an Int32 bitmask (decision a) — the same column shape as group_users. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "grants"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + scope_type TEXT NOT NULL CHECK (scope_type IN ('partner', 'client', 'authority')), + scope_id TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + granted_by TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +-- One grant per (user, scope) — a re-grant updates the existing row's bitmask +-- and expiry rather than stacking duplicates. +CREATE UNIQUE INDEX IF NOT EXISTS grants_user_scope_unique + ON "grants" USING BTREE (user_id, scope_type, scope_id); + +-- "what can this user reach" (resolution walks per user). +CREATE INDEX IF NOT EXISTS grants_user_id_index + ON "grants" USING BTREE (user_id); + +-- "who can reach this scope" (partner/client admin console, audit). +CREATE INDEX IF NOT EXISTS grants_scope_index + ON "grants" USING BTREE (scope_type, scope_id); + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP INDEX IF EXISTS grants_scope_index; +DROP INDEX IF EXISTS grants_user_id_index; +DROP INDEX IF EXISTS grants_user_scope_unique; +DROP TABLE IF EXISTS "grants"; diff --git a/spec/generator.cr b/spec/generator.cr index 1ce8c079..b27cdd89 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -581,6 +581,23 @@ module PlaceOS::Model ) end + def self.grant( + user : User, + scope_type : String, + scope_id : String, + permissions : Permissions = Permissions::Read, + expires_at : Time? = nil, + ) + grant = Grant.new( + user_id: user.id.not_nil!, + scope_type: scope_type, + scope_id: scope_id, + expires_at: expires_at, + ) + grant.permission_flags = permissions + grant + end + def self.user(authority : Authority? = nil, support : Bool = false, admin : Bool = false) unless authority # look up an existing authority diff --git a/spec/grant_spec.cr b/spec/grant_spec.cr new file mode 100644 index 00000000..9c2f2988 --- /dev/null +++ b/spec/grant_spec.cr @@ -0,0 +1,109 @@ +require "./helper" + +# Build partner -> client -> authority -> user, returning all four. +private def build_estate(payer = PlaceOS::Model::Client::PAYER_PARTNER) + partner = PlaceOS::Model::Generator.partner.save! + client = PlaceOS::Model::Generator.client(partner: partner, payer: payer).save! + authority = PlaceOS::Model::Generator.authority(domain: "grant-#{RANDOM.hex(4)}.example.com") + authority.client_id = client.id + authority.save! + user = PlaceOS::Model::Generator.user(authority: authority).save! + {partner, client, authority, user} +end + +module PlaceOS::Model + describe Grant do + Spec.before_each do + Grant.clear + Authority.clear + User.clear + Client.clear + Partner.clear + end + + it "saves a grant with a permission bitmask" do + _, client, _, user = build_estate + grant = Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Manage).save! + grant.persisted?.should be_true + grant.permission_flags.should eq Permissions::Manage + end + + it "rejects an unknown scope_type" do + _, _, _, user = build_estate + grant = Generator.grant(user, "galaxy", "scope-1") + grant.valid?.should be_false + grant.errors.map(&.field).should contain(:scope_type) + end + + it "resolves an authority-scope grant" do + _, _, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_AUTHORITY, authority.id.not_nil!, Permissions::Update).save! + Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Update + end + + it "resolves a client-scope grant onto the client's authority" do + _, client, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Operate).save! + Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Operate + end + + it "resolves a partner-scope grant onto every authority under that partner (decision b)" do + partner = Generator.partner.save! + client_a = Generator.client(partner: partner).save! + client_b = Generator.client(partner: partner).save! + auth_a = Generator.authority(domain: "a-#{RANDOM.hex(4)}.example.com") + auth_a.client_id = client_a.id + auth_a.save! + auth_b = Generator.authority(domain: "b-#{RANDOM.hex(4)}.example.com") + auth_b.client_id = client_b.id + auth_b.save! + staff = Generator.user(authority: auth_a).save! + + # One grant at partner scope reaches both clients' authorities. + Generator.grant(staff, Grant::SCOPE_PARTNER, partner.id.not_nil!.to_s, Permissions::Manage).save! + Grant.resolve(staff.id.not_nil!, auth_a).should eq Permissions::Manage + Grant.resolve(staff.id.not_nil!, auth_b).should eq Permissions::Manage + end + + it "ORs permissions across scopes on the chain" do + partner, client, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_PARTNER, partner.id.not_nil!.to_s, Permissions::Read).save! + Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Update).save! + Grant.resolve(user.id.not_nil!, authority).should eq(Permissions::Read | Permissions::Update) + end + + it "does not leak a grant into a different partner's estate" do + _, _, authority_one, user = build_estate + # A second, unrelated estate. + _, _, authority_two, _ = build_estate + Generator.grant(user, Grant::SCOPE_AUTHORITY, authority_one.id.not_nil!, Permissions::Manage).save! + Grant.resolve(user.id.not_nil!, authority_two).should eq Permissions::None + end + + it "ignores expired grants" do + _, client, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Manage, + expires_at: Time.utc - 1.hour).save! + Grant.resolve(user.id.not_nil!, authority).should eq Permissions::None + end + + it "honours a future expiry" do + _, client, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Read, + expires_at: Time.utc + 1.hour).save! + Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Read + end + + it "returns None for a resource with no matching grant" do + _, _, authority, user = build_estate + Grant.resolve(user.id.not_nil!, authority).should eq Permissions::None + end + + it "cascades on user delete" do + _, client, _, user = build_estate + grant = Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s).save! + user.destroy + Grant.find?(grant.id.not_nil!).should be_nil + end + end +end diff --git a/spec/helper.cr b/spec/helper.cr index e4a76705..32e8e6d4 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -39,6 +39,7 @@ Spec.after_suite do PlaceOS::Model::GroupPlaylistItem, PlaceOS::Model::GroupPlaylist, PlaceOS::Model::Group, + PlaceOS::Model::Grant, PlaceOS::Model::Client, PlaceOS::Model::Partner, ].each(&.clear) diff --git a/src/placeos-models/grant.cr b/src/placeos-models/grant.cr new file mode 100644 index 00000000..66243003 --- /dev/null +++ b/src/placeos-models/grant.cr @@ -0,0 +1,135 @@ +require "uuid" +require "uuid/json" + +require "./base/model" +require "./permissions" +require "./authority" +require "./client" +require "./partner" + +module PlaceOS::Model + # PPT-526 Stage 3: a cross-tenant authorization grant. + # + # Gives a user a `Permissions` bitmask over one scope — a Partner, a Client, + # or an Authority. Authorization resolves by walking UP from the touched + # resource: authority -> its client -> that client's partner. A grant at + # partner scope therefore covers every one of that partner's clients, current + # and future (the NTT-over-its-clients case); a grant at client scope covers + # that client's authorities; a grant at authority scope is a single domain. + # + # `scope_id` is polymorphic (a Partner/Client UUID as text, or an Authority + # TEXT id) so it carries no FK; a grant naming a deleted scope is inert. + class Grant < ::PgORM::Base + include PgORM::Timestamps + + table :grants + + SCOPE_PARTNER = "partner" + SCOPE_CLIENT = "client" + SCOPE_AUTHORITY = "authority" + SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_CLIENT, SCOPE_AUTHORITY] + + default_primary_key id : UUID, autogenerated: true + + attribute user_id : String + belongs_to :user, class_name: User, foreign_key: user_id + + attribute scope_type : String + attribute scope_id : String + + # Stored as an Int32 bitmask. Use `permission_flags` / `permission_flags=` + # to work with the `Permissions` flags enum directly (same shape as + # group_users.permissions). + attribute permissions : Int32 = 0 + + # NULL = a standing grant. A non-null value in the past means expired. + # Plain Time maps to the TIMESTAMPTZ column (same as created_at/updated_at). + attribute expires_at : Time? + + # The user id of whoever issued this grant (audit; no FK so the trail + # survives the grantor's deletion). + attribute granted_by : String? + + validates :user_id, presence: true + validates :scope_id, presence: true + + validate ->(this : Grant) { + unless SCOPE_TYPES.includes?(this.scope_type) + this.validation_error(:scope_type, "must be one of #{SCOPE_TYPES.join(", ")}") + end + } + + def permission_flags : Permissions + Permissions.new(self.permissions) + end + + def permission_flags=(flags : Permissions) + self.permissions = flags.to_i + end + + # A grant is live when it has not expired. + def live?(at : Time = Time.utc) : Bool + exp = self.expires_at + exp.nil? || exp > at + end + + # ------------------------------------------------------------------ + # Resolution + # ------------------------------------------------------------------ + + # Effective permissions a user holds over `authority`, resolved by walking + # up authority -> client -> partner and OR-ing every live grant on the + # chain. Explicit grants only; the management-partner "support sees all" + # rule is applied by the enforcement layer, not here. + def self.resolve(user_id : String, authority : Authority, at : Time = Time.utc) : Permissions + resolve_for_chain(user_id, scope_chain(authority), at) + end + + # As above, addressed by authority id (returns None for an unknown id). + def self.resolve(user_id : String, authority_id : String, at : Time = Time.utc) : Permissions + authority = Authority.find?(authority_id) + return Permissions::None if authority.nil? + resolve(user_id, authority, at) + end + + # The (scope_type, scope_id) pairs to check for a given authority, from + # most specific to least: the authority itself, its client, that client's + # partner. Missing links are simply absent. + def self.scope_chain(authority : Authority) : Array({String, String}) + chain = [{SCOPE_AUTHORITY, authority.id.to_s}] + if (client_id = authority.client_id) + chain << {SCOPE_CLIENT, client_id.to_s} + if (client = Client.find?(client_id)) && (partner_id = client.partner_id) + chain << {SCOPE_PARTNER, partner_id.to_s} + end + end + chain + end + + private def self.resolve_for_chain(user_id : String, chain : Array({String, String}), at : Time) : Permissions + return Permissions::None if chain.empty? + + # Query by the scalar user_id (a user holds few grants), then keep only + # the live grants whose (scope_type, scope_id) is on the resource's chain + # and OR their permissions. Filtering in memory sidesteps array binding. + wanted = chain.to_set + effective = Permissions::None + Grant.where(user_id: user_id).each do |grant| + next unless wanted.includes?({grant.scope_type, grant.scope_id}) + next unless grant.live?(at) + effective |= grant.permission_flags + end + effective + end + + # All live grants for a user (any scope). + def self.for_user(user_id : String) : Array(Grant) + Grant.where(user_id: user_id).select(&.live?) + end + + # Everyone granted access to a specific scope (partner/client admin console). + def self.for_scope(scope_type : String, scope_id : String) : Array(Grant) + Grant.where(scope_type: scope_type, scope_id: scope_id).to_a + end + end +end From 9564cea81510849e589f1d1a83d15a7fd0a192a2 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 19 Aug 2026 15:27:00 +1000 Subject: [PATCH 3/6] refactor: PPT-526 rename Client to Organization Steve's multi-tenancy-template calls the customer tenant "Organization"; we align on that name (decided 2026-08-19). The Partner tier above it is unchanged. Since nothing is shipped, the migrations are rewritten in place rather than adding a rename migration. - clients table -> organizations; client_id -> organization_id on authority/zone/sys/mod/trigger/edge/broker; the Client model -> Organization (payer value 'client' -> 'organization'; client_owned? -> self_managed?). - grants scope_type value 'client' -> 'organization'; Grant.resolve walk now authority -> organization -> partner. - migrations, backfill, generator, specs, helper, and backfill-verify.sh all updated; the OAuth/credential `client_id` fields (oauth_strat, tenant credentials JSON) are deliberately left as-is. Verified: full models suite 671 examples, 0 failures (the 1 error is the pre-existing flaky admin_destroy_lock concurrency test); organization/partner/ grant specs 28 green; backfill-verify.sh all assertions pass; ameba clean. --- ...0818100500000_add_partners_and_clients.sql | 211 ------------------ ...0500000_add_partners_and_organizations.sql | 211 ++++++++++++++++++ ...00000_backfill_organization_ownership.sql} | 118 +++++----- .../20260819100500000_add_grants.sql | 12 +- spec/client_spec.cr | 104 --------- spec/generator.cr | 4 +- spec/grant_spec.cr | 46 ++-- spec/helper.cr | 2 +- spec/organization_spec.cr | 104 +++++++++ spec/partner_spec.cr | 10 +- src/placeos-models/authority.cr | 10 +- src/placeos-models/broker.cr | 10 +- src/placeos-models/control_system.cr | 10 +- src/placeos-models/edge.cr | 10 +- src/placeos-models/grant.cr | 34 +-- src/placeos-models/module.cr | 12 +- .../{client.cr => organization.cr} | 41 ++-- src/placeos-models/partner.cr | 6 +- src/placeos-models/trigger.cr | 10 +- src/placeos-models/zone.cr | 10 +- 20 files changed, 488 insertions(+), 487 deletions(-) delete mode 100644 migration/db/migrations/20260818100500000_add_partners_and_clients.sql create mode 100644 migration/db/migrations/20260818100500000_add_partners_and_organizations.sql rename migration/db/migrations/{20260818100600000_backfill_client_ownership.sql => 20260818100600000_backfill_organization_ownership.sql} (63%) delete mode 100644 spec/client_spec.cr create mode 100644 spec/organization_spec.cr rename src/placeos-models/{client.cr => organization.cr} (57%) diff --git a/migration/db/migrations/20260818100500000_add_partners_and_clients.sql b/migration/db/migrations/20260818100500000_add_partners_and_clients.sql deleted file mode 100644 index 59478ca6..00000000 --- a/migration/db/migrations/20260818100500000_add_partners_and_clients.sql +++ /dev/null @@ -1,211 +0,0 @@ --- +micrate Up --- SQL in section 'Up' is executed when this migration is applied - --- MERGE-TIME CHECK: micrate refuses out-of-order version ids (see models#323). --- If any later-dated migration lands on master before this merges, renumber --- this file AND 20260818100600000 above it (preserving schema-before-backfill --- order). Safe: neither has been applied anywhere and the backfill is --- idempotent. - --- --------------------------------------------------------------------------- --- PPT-526: the Partner → Client → Authority(domain) hierarchy. --- --- partners: integrators/resellers (e.g. NTT). `management = true` marks the --- platform operator's own organisation (PlaceOS staff) — the "management --- client" concept from the ticket, done at the partner level. `parent_id` --- reserves a 2-tier channel (distributor → reseller) without any UI or --- enforcement yet. --- --- clients: the customer organisation (e.g. UCLA, Acadian). `partner_id` NULL --- means client-owned (self-managed, no integrator). `payer` records who is --- invoiced: 'partner' (integrator pays us and re-bills, the default channel --- model) or 'client' (direct). A client without a partner can only pay for --- itself — enforced by CHECK. --- --- Every delete path is RESTRICT: removing a partner/client must go through an --- orchestrated teardown (the PPT-1203 pattern), never a silent DB cascade — --- model-level cleanup callbacks do not fire on DB cascades. --- --------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS "partners"( - id UUID PRIMARY KEY DEFAULT uuidv7(), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - management BOOLEAN NOT NULL DEFAULT false, - parent_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS partners_name_unique - ON "partners" USING BTREE (name); -CREATE INDEX IF NOT EXISTS partners_parent_id_index - ON "partners" USING BTREE (parent_id); - -CREATE TABLE IF NOT EXISTS "clients"( - id UUID PRIMARY KEY DEFAULT uuidv7(), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - partner_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, - payer TEXT NOT NULL DEFAULT 'partner' - CHECK (payer IN ('partner', 'client')), - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - CONSTRAINT clients_payer_requires_partner - CHECK (partner_id IS NOT NULL OR payer = 'client') -); - --- Client names are unique within a partner's book of business, and unique --- among client-owned clients. Two partial indexes because UNIQUE treats --- NULLs as distinct — a plain UNIQUE (partner_id, name) would let every --- client-owned client share a name. -CREATE UNIQUE INDEX IF NOT EXISTS clients_name_unique_per_partner - ON "clients" USING BTREE (partner_id, name) WHERE partner_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS clients_name_unique_owned - ON "clients" USING BTREE (name) WHERE partner_id IS NULL; -CREATE INDEX IF NOT EXISTS clients_partner_id_index - ON "clients" USING BTREE (partner_id); - --- --------------------------------------------------------------------------- --- Authority → Client ownership. Nullable at the DB level for backwards --- compatibility (init seeds authorities before any client exists); the --- provisioning flow and backfill populate it. RESTRICT so a client cannot be --- deleted while it still owns domains. --- --------------------------------------------------------------------------- -ALTER TABLE "authority" - ADD COLUMN IF NOT EXISTS client_id UUID; - -ALTER TABLE ONLY "authority" - DROP CONSTRAINT IF EXISTS authority_client_id_fkey; -ALTER TABLE ONLY "authority" - ADD CONSTRAINT authority_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; - -CREATE INDEX IF NOT EXISTS authority_client_id_index - ON "authority" USING BTREE (client_id); - --- The domain column has only ever had model-level uniqueness (a plain BTREE --- index) — a concurrent-signup race can create duplicate authorities. Make it --- a hard guarantee; self-service signup depends on it. --- --- Quarantine first: exact-duplicate domains left behind by that race would --- make the unique index unbuildable and (because micrate autocommits each --- statement) leave this migration half-applied. Keep the oldest row per --- domain and rename the rest so they stop answering for the domain — --- find_by_domain picks an arbitrary row across duplicates today, so this --- cannot break a reliably-working login. Idempotent: renamed rows no longer --- collide. Renamed rows are for an operator to merge or delete. -UPDATE "authority" a -SET domain = a.domain || '.duplicate.' || a.id -WHERE EXISTS ( - SELECT 1 FROM "authority" b - WHERE b.domain = a.domain - AND (b.created_at, b.id) < (a.created_at, a.id) -); - -CREATE UNIQUE INDEX IF NOT EXISTS authority_domain_unique - ON "authority" USING BTREE (domain); - --- --------------------------------------------------------------------------- --- Client ownership columns on the (previously cluster-global) control plane. --- Nullable: populated by the backfill migration where inference is --- unambiguous, and by the provisioning flow for everything new. Query-path --- enforcement is phased in separately (rest-api); these columns are the --- ground truth it will filter on. --- --------------------------------------------------------------------------- -ALTER TABLE "zone" ADD COLUMN IF NOT EXISTS client_id UUID; -ALTER TABLE "sys" ADD COLUMN IF NOT EXISTS client_id UUID; -ALTER TABLE "mod" ADD COLUMN IF NOT EXISTS client_id UUID; -ALTER TABLE "trigger" ADD COLUMN IF NOT EXISTS client_id UUID; -ALTER TABLE "edge" ADD COLUMN IF NOT EXISTS client_id UUID; -ALTER TABLE "broker" ADD COLUMN IF NOT EXISTS client_id UUID; - -ALTER TABLE ONLY "zone" - DROP CONSTRAINT IF EXISTS zone_client_id_fkey; -ALTER TABLE ONLY "zone" - ADD CONSTRAINT zone_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "sys" - DROP CONSTRAINT IF EXISTS sys_client_id_fkey; -ALTER TABLE ONLY "sys" - ADD CONSTRAINT sys_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "mod" - DROP CONSTRAINT IF EXISTS mod_client_id_fkey; -ALTER TABLE ONLY "mod" - ADD CONSTRAINT mod_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "trigger" - DROP CONSTRAINT IF EXISTS trigger_client_id_fkey; -ALTER TABLE ONLY "trigger" - ADD CONSTRAINT trigger_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "edge" - DROP CONSTRAINT IF EXISTS edge_client_id_fkey; -ALTER TABLE ONLY "edge" - ADD CONSTRAINT edge_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "broker" - DROP CONSTRAINT IF EXISTS broker_client_id_fkey; -ALTER TABLE ONLY "broker" - ADD CONSTRAINT broker_client_id_fkey - FOREIGN KEY (client_id) REFERENCES "clients"(id) ON DELETE RESTRICT; - -CREATE INDEX IF NOT EXISTS zone_client_id_index ON "zone" USING BTREE (client_id); -CREATE INDEX IF NOT EXISTS sys_client_id_index ON "sys" USING BTREE (client_id); -CREATE INDEX IF NOT EXISTS mod_client_id_index ON "mod" USING BTREE (client_id); -CREATE INDEX IF NOT EXISTS trigger_client_id_index ON "trigger" USING BTREE (client_id); -CREATE INDEX IF NOT EXISTS edge_client_id_index ON "edge" USING BTREE (client_id); -CREATE INDEX IF NOT EXISTS broker_client_id_index ON "broker" USING BTREE (client_id); - --- Per-client name uniqueness for the estate. Dormant while client_id is NULL --- (UNIQUE treats NULLs as distinct) and strictly weaker than the current --- model-level global uniqueness, so it cannot conflict with existing data. --- When query enforcement lands, the model-level checks flip from global to --- client-scoped and these indexes become the hard guarantee. -CREATE UNIQUE INDEX IF NOT EXISTS zone_client_id_name_unique - ON "zone" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS sys_client_id_name_unique - ON "sys" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS edge_client_id_name_unique - ON "edge" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS broker_client_id_name_unique - ON "broker" USING BTREE (client_id, name) WHERE client_id IS NOT NULL; - --- +micrate Down --- SQL section 'Down' is executed when this migration is rolled back - -DROP INDEX IF EXISTS broker_client_id_name_unique; -DROP INDEX IF EXISTS edge_client_id_name_unique; -DROP INDEX IF EXISTS sys_client_id_name_unique; -DROP INDEX IF EXISTS zone_client_id_name_unique; - -DROP INDEX IF EXISTS broker_client_id_index; -DROP INDEX IF EXISTS edge_client_id_index; -DROP INDEX IF EXISTS trigger_client_id_index; -DROP INDEX IF EXISTS mod_client_id_index; -DROP INDEX IF EXISTS sys_client_id_index; -DROP INDEX IF EXISTS zone_client_id_index; - -ALTER TABLE ONLY "broker" DROP CONSTRAINT IF EXISTS broker_client_id_fkey; -ALTER TABLE ONLY "edge" DROP CONSTRAINT IF EXISTS edge_client_id_fkey; -ALTER TABLE ONLY "trigger" DROP CONSTRAINT IF EXISTS trigger_client_id_fkey; -ALTER TABLE ONLY "mod" DROP CONSTRAINT IF EXISTS mod_client_id_fkey; -ALTER TABLE ONLY "sys" DROP CONSTRAINT IF EXISTS sys_client_id_fkey; -ALTER TABLE ONLY "zone" DROP CONSTRAINT IF EXISTS zone_client_id_fkey; - -ALTER TABLE "broker" DROP COLUMN IF EXISTS client_id; -ALTER TABLE "edge" DROP COLUMN IF EXISTS client_id; -ALTER TABLE "trigger" DROP COLUMN IF EXISTS client_id; -ALTER TABLE "mod" DROP COLUMN IF EXISTS client_id; -ALTER TABLE "sys" DROP COLUMN IF EXISTS client_id; -ALTER TABLE "zone" DROP COLUMN IF EXISTS client_id; - -DROP INDEX IF EXISTS authority_domain_unique; -DROP INDEX IF EXISTS authority_client_id_index; -ALTER TABLE ONLY "authority" DROP CONSTRAINT IF EXISTS authority_client_id_fkey; -ALTER TABLE "authority" DROP COLUMN IF EXISTS client_id; - -DROP TABLE IF EXISTS "clients"; -DROP TABLE IF EXISTS "partners"; diff --git a/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql b/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql new file mode 100644 index 00000000..524e9084 --- /dev/null +++ b/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql @@ -0,0 +1,211 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- MERGE-TIME CHECK: micrate refuses out-of-order version ids (see models#323). +-- If any later-dated migration lands on master before this merges, renumber +-- this file AND 20260818100600000 above it (preserving schema-before-backfill +-- order). Safe: neither has been applied anywhere and the backfill is +-- idempotent. + +-- --------------------------------------------------------------------------- +-- PPT-526: the Partner → Organization → Authority(domain) hierarchy. +-- +-- partners: integrators/resellers (e.g. NTT). `management = true` marks the +-- platform operator's own organisation (PlaceOS staff) — the "management +-- organization" concept from the ticket, done at the partner level. `parent_id` +-- reserves a 2-tier channel (distributor → reseller) without any UI or +-- enforcement yet. +-- +-- organizations: the customer organisation (e.g. UCLA, Acadian). `partner_id` NULL +-- means organization-owned (self-managed, no integrator). `payer` records who is +-- invoiced: 'partner' (integrator pays us and re-bills, the default channel +-- model) or 'organization' (direct). A organization without a partner can only pay for +-- itself — enforced by CHECK. +-- +-- Every delete path is RESTRICT: removing a partner/organization must go through an +-- orchestrated teardown (the PPT-1203 pattern), never a silent DB cascade — +-- model-level cleanup callbacks do not fire on DB cascades. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "partners"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + management BOOLEAN NOT NULL DEFAULT false, + parent_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS partners_name_unique + ON "partners" USING BTREE (name); +CREATE INDEX IF NOT EXISTS partners_parent_id_index + ON "partners" USING BTREE (parent_id); + +CREATE TABLE IF NOT EXISTS "organizations"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + partner_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + payer TEXT NOT NULL DEFAULT 'partner' + CHECK (payer IN ('partner', 'organization')), + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT organizations_payer_requires_partner + CHECK (partner_id IS NOT NULL OR payer = 'organization') +); + +-- Organization names are unique within a partner's book of business, and unique +-- among organization-owned organizations. Two partial indexes because UNIQUE treats +-- NULLs as distinct — a plain UNIQUE (partner_id, name) would let every +-- organization-owned organization share a name. +CREATE UNIQUE INDEX IF NOT EXISTS organizations_name_unique_per_partner + ON "organizations" USING BTREE (partner_id, name) WHERE partner_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS organizations_name_unique_owned + ON "organizations" USING BTREE (name) WHERE partner_id IS NULL; +CREATE INDEX IF NOT EXISTS organizations_partner_id_index + ON "organizations" USING BTREE (partner_id); + +-- --------------------------------------------------------------------------- +-- Authority → Organization ownership. Nullable at the DB level for backwards +-- compatibility (init seeds authorities before any organization exists); the +-- provisioning flow and backfill populate it. RESTRICT so a organization cannot be +-- deleted while it still owns domains. +-- --------------------------------------------------------------------------- +ALTER TABLE "authority" + ADD COLUMN IF NOT EXISTS organization_id UUID; + +ALTER TABLE ONLY "authority" + DROP CONSTRAINT IF EXISTS authority_organization_id_fkey; +ALTER TABLE ONLY "authority" + ADD CONSTRAINT authority_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS authority_organization_id_index + ON "authority" USING BTREE (organization_id); + +-- The domain column has only ever had model-level uniqueness (a plain BTREE +-- index) — a concurrent-signup race can create duplicate authorities. Make it +-- a hard guarantee; self-service signup depends on it. +-- +-- Quarantine first: exact-duplicate domains left behind by that race would +-- make the unique index unbuildable and (because micrate autocommits each +-- statement) leave this migration half-applied. Keep the oldest row per +-- domain and rename the rest so they stop answering for the domain — +-- find_by_domain picks an arbitrary row across duplicates today, so this +-- cannot break a reliably-working login. Idempotent: renamed rows no longer +-- collide. Renamed rows are for an operator to merge or delete. +UPDATE "authority" a +SET domain = a.domain || '.duplicate.' || a.id +WHERE EXISTS ( + SELECT 1 FROM "authority" b + WHERE b.domain = a.domain + AND (b.created_at, b.id) < (a.created_at, a.id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS authority_domain_unique + ON "authority" USING BTREE (domain); + +-- --------------------------------------------------------------------------- +-- Organization ownership columns on the (previously cluster-global) control plane. +-- Nullable: populated by the backfill migration where inference is +-- unambiguous, and by the provisioning flow for everything new. Query-path +-- enforcement is phased in separately (rest-api); these columns are the +-- ground truth it will filter on. +-- --------------------------------------------------------------------------- +ALTER TABLE "zone" ADD COLUMN IF NOT EXISTS organization_id UUID; +ALTER TABLE "sys" ADD COLUMN IF NOT EXISTS organization_id UUID; +ALTER TABLE "mod" ADD COLUMN IF NOT EXISTS organization_id UUID; +ALTER TABLE "trigger" ADD COLUMN IF NOT EXISTS organization_id UUID; +ALTER TABLE "edge" ADD COLUMN IF NOT EXISTS organization_id UUID; +ALTER TABLE "broker" ADD COLUMN IF NOT EXISTS organization_id UUID; + +ALTER TABLE ONLY "zone" + DROP CONSTRAINT IF EXISTS zone_organization_id_fkey; +ALTER TABLE ONLY "zone" + ADD CONSTRAINT zone_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "sys" + DROP CONSTRAINT IF EXISTS sys_organization_id_fkey; +ALTER TABLE ONLY "sys" + ADD CONSTRAINT sys_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "mod" + DROP CONSTRAINT IF EXISTS mod_organization_id_fkey; +ALTER TABLE ONLY "mod" + ADD CONSTRAINT mod_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "trigger" + DROP CONSTRAINT IF EXISTS trigger_organization_id_fkey; +ALTER TABLE ONLY "trigger" + ADD CONSTRAINT trigger_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "edge" + DROP CONSTRAINT IF EXISTS edge_organization_id_fkey; +ALTER TABLE ONLY "edge" + ADD CONSTRAINT edge_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "broker" + DROP CONSTRAINT IF EXISTS broker_organization_id_fkey; +ALTER TABLE ONLY "broker" + ADD CONSTRAINT broker_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS zone_organization_id_index ON "zone" USING BTREE (organization_id); +CREATE INDEX IF NOT EXISTS sys_organization_id_index ON "sys" USING BTREE (organization_id); +CREATE INDEX IF NOT EXISTS mod_organization_id_index ON "mod" USING BTREE (organization_id); +CREATE INDEX IF NOT EXISTS trigger_organization_id_index ON "trigger" USING BTREE (organization_id); +CREATE INDEX IF NOT EXISTS edge_organization_id_index ON "edge" USING BTREE (organization_id); +CREATE INDEX IF NOT EXISTS broker_organization_id_index ON "broker" USING BTREE (organization_id); + +-- Per-organization name uniqueness for the estate. Dormant while organization_id is NULL +-- (UNIQUE treats NULLs as distinct) and strictly weaker than the current +-- model-level global uniqueness, so it cannot conflict with existing data. +-- When query enforcement lands, the model-level checks flip from global to +-- organization-scoped and these indexes become the hard guarantee. +CREATE UNIQUE INDEX IF NOT EXISTS zone_organization_id_name_unique + ON "zone" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS sys_organization_id_name_unique + ON "sys" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS edge_organization_id_name_unique + ON "edge" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS broker_organization_id_name_unique + ON "broker" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP INDEX IF EXISTS broker_organization_id_name_unique; +DROP INDEX IF EXISTS edge_organization_id_name_unique; +DROP INDEX IF EXISTS sys_organization_id_name_unique; +DROP INDEX IF EXISTS zone_organization_id_name_unique; + +DROP INDEX IF EXISTS broker_organization_id_index; +DROP INDEX IF EXISTS edge_organization_id_index; +DROP INDEX IF EXISTS trigger_organization_id_index; +DROP INDEX IF EXISTS mod_organization_id_index; +DROP INDEX IF EXISTS sys_organization_id_index; +DROP INDEX IF EXISTS zone_organization_id_index; + +ALTER TABLE ONLY "broker" DROP CONSTRAINT IF EXISTS broker_organization_id_fkey; +ALTER TABLE ONLY "edge" DROP CONSTRAINT IF EXISTS edge_organization_id_fkey; +ALTER TABLE ONLY "trigger" DROP CONSTRAINT IF EXISTS trigger_organization_id_fkey; +ALTER TABLE ONLY "mod" DROP CONSTRAINT IF EXISTS mod_organization_id_fkey; +ALTER TABLE ONLY "sys" DROP CONSTRAINT IF EXISTS sys_organization_id_fkey; +ALTER TABLE ONLY "zone" DROP CONSTRAINT IF EXISTS zone_organization_id_fkey; + +ALTER TABLE "broker" DROP COLUMN IF EXISTS organization_id; +ALTER TABLE "edge" DROP COLUMN IF EXISTS organization_id; +ALTER TABLE "trigger" DROP COLUMN IF EXISTS organization_id; +ALTER TABLE "mod" DROP COLUMN IF EXISTS organization_id; +ALTER TABLE "sys" DROP COLUMN IF EXISTS organization_id; +ALTER TABLE "zone" DROP COLUMN IF EXISTS organization_id; + +DROP INDEX IF EXISTS authority_domain_unique; +DROP INDEX IF EXISTS authority_organization_id_index; +ALTER TABLE ONLY "authority" DROP CONSTRAINT IF EXISTS authority_organization_id_fkey; +ALTER TABLE "authority" DROP COLUMN IF EXISTS organization_id; + +DROP TABLE IF EXISTS "organizations"; +DROP TABLE IF EXISTS "partners"; diff --git a/migration/db/migrations/20260818100600000_backfill_client_ownership.sql b/migration/db/migrations/20260818100600000_backfill_organization_ownership.sql similarity index 63% rename from migration/db/migrations/20260818100600000_backfill_client_ownership.sql rename to migration/db/migrations/20260818100600000_backfill_organization_ownership.sql index f26b266b..35d7ed60 100644 --- a/migration/db/migrations/20260818100600000_backfill_client_ownership.sql +++ b/migration/db/migrations/20260818100600000_backfill_organization_ownership.sql @@ -2,10 +2,10 @@ -- SQL in section 'Up' is executed when this migration is applied -- --------------------------------------------------------------------------- --- PPT-526: backfill Partner/Client ownership for existing data. +-- PPT-526: backfill Partner/Organization ownership for existing data. -- -- Idempotent and additive: every statement only creates missing rows or fills --- NULL client_id columns, so it is safe to re-apply and safe on databases +-- NULL organization_id columns, so it is safe to re-apply and safe on databases -- where operators have already assigned ownership by hand. Where inference is -- ambiguous the row is deliberately LEFT NULL for a human to resolve (the -- org_zone convention is known to be non-exclusive and sometimes absent). @@ -13,15 +13,15 @@ -- What it does: -- 1. Ensures a management partner exists ("PlaceOS" — the platform -- operator's own organisation). --- 2. Creates one client-owned Client per Authority that has none, named +-- 2. Creates one organization-owned Organization per Authority that has none, named -- " ()" (domain uniqueness makes this collision -- free), and points the authority at it. Estates that span several --- authorities under one real-world client (e.g. NTT's two domains) are --- merged later by re-pointing authority.client_id by hand or API. --- 3. Zones: a zone belongs to the client of the authority whose +-- authorities under one real-world organization (e.g. NTT's two domains) are +-- merged later by re-pointing authority.organization_id by hand or API. +-- 3. Zones: a zone belongs to the organization of the authority whose -- config->>'org_zone' names the zone's ROOT zone — only when exactly one --- client is implied (shared org zones stay NULL). --- 4. Systems: the single distinct client of their zones, if unambiguous. +-- organization is implied (shared org zones stay NULL). +-- 4. Systems: the single distinct organization of their zones, if unambiguous. -- 5. Modules: logic modules via their control system; shared device/service -- modules via the systems that reference them, if unambiguous. -- 6. Trigger definitions: via their control system when system-scoped; @@ -38,34 +38,34 @@ SELECT 'PlaceOS', 'Platform operator (management partner)', true, now(), now() WHERE NOT EXISTS (SELECT 1 FROM "partners" WHERE management = true) ON CONFLICT DO NOTHING; --- 2. One client-owned Client per orphan Authority +-- 2. One organization-owned Organization per orphan Authority -- +micrate StatementBegin DO $$ DECLARE auth RECORD; - new_client UUID; + new_org UUID; BEGIN - FOR auth IN SELECT id, name, domain FROM "authority" WHERE client_id IS NULL LOOP - new_client := NULL; - INSERT INTO "clients" (name, description, partner_id, payer, created_at, updated_at) + FOR auth IN SELECT id, name, domain FROM "authority" WHERE organization_id IS NULL LOOP + new_org := NULL; + INSERT INTO "organizations" (name, description, partner_id, payer, created_at, updated_at) VALUES ( COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')', - 'Auto-created from authority ' || auth.id || ' during PPT-526 client backfill', - NULL, 'client', now(), now() + 'Auto-created from authority ' || auth.id || ' during PPT-526 organization backfill', + NULL, 'organization', now(), now() ) ON CONFLICT DO NOTHING - RETURNING id INTO new_client; + RETURNING id INTO new_org; -- If the name already existed (re-run against a partially-backfilled DB), -- reuse the existing row rather than leaving the authority orphaned. - IF new_client IS NULL THEN - SELECT id INTO new_client FROM "clients" + IF new_org IS NULL THEN + SELECT id INTO new_org FROM "organizations" WHERE partner_id IS NULL AND name = COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')'; END IF; - IF new_client IS NOT NULL THEN - UPDATE "authority" SET client_id = new_client WHERE id = auth.id; + IF new_org IS NOT NULL THEN + UPDATE "authority" SET organization_id = new_org WHERE id = auth.id; END IF; END LOOP; END $$; @@ -75,9 +75,9 @@ END $$; -- UNION (not UNION ALL) so accidental cycles in zone parentage terminate. -- Ambiguity is judged per TREE, not per org_zone string: every authority's -- org_zone claim is resolved to its tree root, a tree claimed by more than --- one distinct client is vetoed, and ownership is only assigned when at +-- one distinct organization is vetoed, and ownership is only assigned when at -- least one claim names the root itself (a claim on a sub-zone alone must --- not annex the whole tree). Rows whose (client_id, name) would collide +-- not annex the whole tree). Rows whose (organization_id, name) would collide -- with the partial unique indexes are skipped (left NULL for a human) — -- duplicate names within one estate are legacy race artifacts. -- +micrate StatementBegin @@ -93,125 +93,125 @@ WITH RECURSIVE zone_roots AS ( tree_claims AS ( SELECT r.root_id, (a.config->>'org_zone' = r.root_id) AS names_root, - a.client_id + a.organization_id FROM "authority" a INNER JOIN zone_roots r ON r.id = a.config->>'org_zone' WHERE COALESCE(a.config->>'org_zone', '') <> '' - AND a.client_id IS NOT NULL + AND a.organization_id IS NOT NULL ), org_owner AS ( - SELECT root_id, MIN(client_id::text)::uuid AS client_id + SELECT root_id, MIN(organization_id::text)::uuid AS organization_id FROM tree_claims GROUP BY root_id - HAVING COUNT(DISTINCT client_id) = 1 + HAVING COUNT(DISTINCT organization_id) = 1 AND bool_or(names_root) ), candidates AS ( - SELECT z.id, o.client_id, z.name, + SELECT z.id, o.organization_id, z.name, ROW_NUMBER() OVER ( - PARTITION BY o.client_id, z.name + PARTITION BY o.organization_id, z.name ORDER BY z.created_at, z.id ) AS rn FROM "zone" z INNER JOIN zone_roots r ON z.id = r.id INNER JOIN org_owner o ON r.root_id = o.root_id - WHERE z.client_id IS NULL + WHERE z.organization_id IS NULL ) UPDATE "zone" z -SET client_id = c.client_id +SET organization_id = c.organization_id FROM candidates c WHERE z.id = c.id AND c.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "zone" x - WHERE x.client_id = c.client_id AND x.name = z.name AND x.id <> z.id + WHERE x.organization_id = c.organization_id AND x.name = z.name AND x.id <> z.id ); -- +micrate StatementEnd --- 4. Systems: single distinct client across their zones. Same collision --- skip as zones (sys carries a (client_id, name) partial unique index). +-- 4. Systems: single distinct organization across their zones. Same collision +-- skip as zones (sys carries a (organization_id, name) partial unique index). -- +micrate StatementBegin WITH sys_candidates AS ( SELECT s2.id AS sys_id, s2.name, - MIN(z.client_id::text)::uuid AS client_id + MIN(z.organization_id::text)::uuid AS organization_id FROM "sys" s2 INNER JOIN "zone" z ON z.id = ANY(s2.zones) - WHERE z.client_id IS NOT NULL - AND s2.client_id IS NULL + WHERE z.organization_id IS NOT NULL + AND s2.organization_id IS NULL GROUP BY s2.id, s2.name - HAVING COUNT(DISTINCT z.client_id) = 1 + HAVING COUNT(DISTINCT z.organization_id) = 1 ), ranked AS ( - SELECT sys_id, name, client_id, + SELECT sys_id, name, organization_id, ROW_NUMBER() OVER ( - PARTITION BY client_id, name + PARTITION BY organization_id, name ORDER BY sys_id ) AS rn FROM sys_candidates ) UPDATE "sys" s -SET client_id = r.client_id +SET organization_id = r.organization_id FROM ranked r WHERE s.id = r.sys_id AND r.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "sys" x - WHERE x.client_id = r.client_id AND x.name = s.name AND x.id <> s.id + WHERE x.organization_id = r.organization_id AND x.name = s.name AND x.id <> s.id ); -- +micrate StatementEnd -- 5a. Logic modules via their control system UPDATE "mod" m -SET client_id = s.client_id +SET organization_id = s.organization_id FROM "sys" s WHERE m.control_system_id = s.id - AND s.client_id IS NOT NULL - AND m.client_id IS NULL; + AND s.organization_id IS NOT NULL + AND m.organization_id IS NULL; -- 5b. Device/service modules via the systems that reference them UPDATE "mod" m -SET client_id = sc.client_id +SET organization_id = sc.organization_id FROM ( - SELECT m2.id AS mod_id, MIN(s.client_id::text)::uuid AS client_id + SELECT m2.id AS mod_id, MIN(s.organization_id::text)::uuid AS organization_id FROM "mod" m2 INNER JOIN "sys" s ON m2.id = ANY(s.modules) - WHERE s.client_id IS NOT NULL + WHERE s.organization_id IS NOT NULL GROUP BY m2.id - HAVING COUNT(DISTINCT s.client_id) = 1 + HAVING COUNT(DISTINCT s.organization_id) = 1 ) sc -WHERE m.id = sc.mod_id AND m.client_id IS NULL; +WHERE m.id = sc.mod_id AND m.organization_id IS NULL; -- 6. System-scoped trigger definitions UPDATE "trigger" t -SET client_id = s.client_id +SET organization_id = s.organization_id FROM "sys" s WHERE t.control_system_id = s.id - AND s.client_id IS NOT NULL - AND t.client_id IS NULL; + AND s.organization_id IS NOT NULL + AND t.organization_id IS NULL; -- 7. Edges via their bound user's authority. Same collision skip (edge --- carries a (client_id, name) partial unique index). +-- carries a (organization_id, name) partial unique index). -- +micrate StatementBegin WITH edge_candidates AS ( - SELECT e.id, e.name, a.client_id, + SELECT e.id, e.name, a.organization_id, ROW_NUMBER() OVER ( - PARTITION BY a.client_id, e.name + PARTITION BY a.organization_id, e.name ORDER BY e.id ) AS rn FROM "edge" e INNER JOIN "user" u ON e.user_id = u.id INNER JOIN "authority" a ON u.authority_id = a.id - WHERE a.client_id IS NOT NULL - AND e.client_id IS NULL + WHERE a.organization_id IS NOT NULL + AND e.organization_id IS NULL ) UPDATE "edge" e -SET client_id = c.client_id +SET organization_id = c.organization_id FROM edge_candidates c WHERE e.id = c.id AND c.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "edge" x - WHERE x.client_id = c.client_id AND x.name = e.name AND x.id <> e.id + WHERE x.organization_id = c.organization_id AND x.name = e.name AND x.id <> e.id ); -- +micrate StatementEnd diff --git a/migration/db/migrations/20260819100500000_add_grants.sql b/migration/db/migrations/20260819100500000_add_grants.sql index 521c15f4..f7073c09 100644 --- a/migration/db/migrations/20260819100500000_add_grants.sql +++ b/migration/db/migrations/20260819100500000_add_grants.sql @@ -8,12 +8,12 @@ -- PPT-526 Stage 3: the authorization `grants` table (Zanzibar-lite tuple). -- -- A grant gives a user a permission bitmask over a scope, which is one of a --- Partner, a Client, or an Authority. Authorization walks UP from the touched --- resource (authority -> its client -> that client's partner) and ORs every +-- Partner, a Organization, or an Authority. Authorization walks UP from the touched +-- resource (authority -> its organization -> that organization's partner) and ORs every -- live grant found on the chain, so a single grant at partner scope applies to --- all of that partner's clients, current and future (decision b, 2026-08-19). +-- all of that partner's organizations, current and future (decision b, 2026-08-19). -- --- `scope_id` is polymorphic (a partner/client UUID as text, or an authority +-- `scope_id` is polymorphic (a partner/organization UUID as text, or an authority -- TEXT id), so it carries no DB foreign key. A grant naming a deleted scope is -- inert: resolution walks up from live resources and never matches it, so -- orphan grants are harmless and can be swept lazily. The one real FK is @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS "grants"( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, - scope_type TEXT NOT NULL CHECK (scope_type IN ('partner', 'client', 'authority')), + scope_type TEXT NOT NULL CHECK (scope_type IN ('partner', 'organization', 'authority')), scope_id TEXT NOT NULL, permissions INTEGER NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ, @@ -43,7 +43,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS grants_user_scope_unique CREATE INDEX IF NOT EXISTS grants_user_id_index ON "grants" USING BTREE (user_id); --- "who can reach this scope" (partner/client admin console, audit). +-- "who can reach this scope" (partner/organization admin console, audit). CREATE INDEX IF NOT EXISTS grants_scope_index ON "grants" USING BTREE (scope_type, scope_id); diff --git a/spec/client_spec.cr b/spec/client_spec.cr deleted file mode 100644 index e0f8bb36..00000000 --- a/spec/client_spec.cr +++ /dev/null @@ -1,104 +0,0 @@ -require "./helper" - -module PlaceOS::Model - describe Client do - Spec.before_each do - ControlSystem.clear - Zone.clear - Authority.clear - Client.clear - Partner.clear - end - - it "saves a partnered client with partner-pays default" do - partner = Generator.partner.save! - client = Generator.client(partner: partner).save! - client.persisted?.should be_true - client.payer.should eq Client::PAYER_PARTNER - client.client_owned?.should be_false - client.partner.try(&.id).should eq partner.id - end - - it "normalizes a client-owned client to pay for itself" do - client = Generator.client.save! - client.partner_id.should be_nil - client.payer.should eq Client::PAYER_CLIENT - client.client_owned?.should be_true - end - - it "rejects an unknown payer" do - client = Generator.client(partner: Generator.partner.save!, payer: "nobody") - client.valid?.should be_false - client.errors.map(&.field).should contain(:payer) - end - - it "scopes client name uniqueness to the partner" do - partner_a = Generator.partner.save! - partner_b = Generator.partner.save! - original = Generator.client(partner: partner_a).save! - - # Same name under a different partner is fine - sibling = Generator.client(partner: partner_b) - sibling.name = original.name - sibling.valid?.should be_true - - # Same name under the same partner is not - duplicate = Generator.client(partner: partner_a) - duplicate.name = original.name - duplicate.valid?.should be_false - duplicate.errors.map(&.field).should contain(:name) - end - - it "rejects duplicate names among client-owned clients" do - original = Generator.client.save! - duplicate = Generator.client - duplicate.name = original.name - duplicate.valid?.should be_false - duplicate.errors.map(&.field).should contain(:name) - end - - it "owns authorities via authority.client_id" do - client = Generator.client.save! - authority = Generator.authority(domain: "client-spec.example.com") - authority.client_id = client.id - authority.save! - - authority.client.try(&.id).should eq client.id - client.authorities.to_a.map(&.id).should eq [authority.id] - end - - it "refuses to delete a client that still owns a domain" do - client = Generator.client.save! - authority = Generator.authority(domain: "client-restrict.example.com") - authority.client_id = client.id - authority.save! - - expect_raises(Exception, /foreign key/) { client.destroy } - Client.find?(client.id.not_nil!).should_not be_nil - end - - it "rejects renaming a client onto a sibling's name" do - partner = Generator.partner.save! - original = Generator.client(partner: partner).save! - sibling = Generator.client(partner: partner).save! - - sibling.name = original.name - sibling.valid?.should be_false - sibling.errors.map(&.field).should contain(:name) - end - - it "records client ownership on zones and systems" do - client = Generator.client.save! - zone = Generator.zone - zone.client_id = client.id - zone.save! - - sys = Generator.control_system - sys.client_id = client.id - sys.save! - - Zone.find!(zone.id.not_nil!).client.try(&.id).should eq client.id - ControlSystem.find!(sys.id.not_nil!).client.try(&.id).should eq client.id - end - end -end diff --git a/spec/generator.cr b/spec/generator.cr index b27cdd89..d388fbe1 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -573,8 +573,8 @@ module PlaceOS::Model ) end - def self.client(partner : Partner? = nil, payer : String = Client::PAYER_PARTNER) - Client.new( + def self.organization(partner : Partner? = nil, payer : String = Organization::PAYER_PARTNER) + Organization.new( name: Faker::Hacker.noun + "-" + RANDOM.hex(3), partner_id: partner.try(&.id), payer: payer, diff --git a/spec/grant_spec.cr b/spec/grant_spec.cr index 9c2f2988..ed9bce3d 100644 --- a/spec/grant_spec.cr +++ b/spec/grant_spec.cr @@ -1,14 +1,14 @@ require "./helper" -# Build partner -> client -> authority -> user, returning all four. -private def build_estate(payer = PlaceOS::Model::Client::PAYER_PARTNER) +# Build partner -> org -> authority -> user, returning all four. +private def build_estate(payer = PlaceOS::Model::Organization::PAYER_PARTNER) partner = PlaceOS::Model::Generator.partner.save! - client = PlaceOS::Model::Generator.client(partner: partner, payer: payer).save! + org = PlaceOS::Model::Generator.organization(partner: partner, payer: payer).save! authority = PlaceOS::Model::Generator.authority(domain: "grant-#{RANDOM.hex(4)}.example.com") - authority.client_id = client.id + authority.organization_id = org.id authority.save! user = PlaceOS::Model::Generator.user(authority: authority).save! - {partner, client, authority, user} + {partner, org, authority, user} end module PlaceOS::Model @@ -17,13 +17,13 @@ module PlaceOS::Model Grant.clear Authority.clear User.clear - Client.clear + Organization.clear Partner.clear end it "saves a grant with a permission bitmask" do - _, client, _, user = build_estate - grant = Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Manage).save! + _, org, _, user = build_estate + grant = Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Manage).save! grant.persisted?.should be_true grant.permission_flags.should eq Permissions::Manage end @@ -41,21 +41,21 @@ module PlaceOS::Model Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Update end - it "resolves a client-scope grant onto the client's authority" do - _, client, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Operate).save! + it "resolves a org-scope grant onto the org's authority" do + _, org, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Operate).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Operate end it "resolves a partner-scope grant onto every authority under that partner (decision b)" do partner = Generator.partner.save! - client_a = Generator.client(partner: partner).save! - client_b = Generator.client(partner: partner).save! + client_a = Generator.organization(partner: partner).save! + client_b = Generator.organization(partner: partner).save! auth_a = Generator.authority(domain: "a-#{RANDOM.hex(4)}.example.com") - auth_a.client_id = client_a.id + auth_a.organization_id = client_a.id auth_a.save! auth_b = Generator.authority(domain: "b-#{RANDOM.hex(4)}.example.com") - auth_b.client_id = client_b.id + auth_b.organization_id = client_b.id auth_b.save! staff = Generator.user(authority: auth_a).save! @@ -66,9 +66,9 @@ module PlaceOS::Model end it "ORs permissions across scopes on the chain" do - partner, client, authority, user = build_estate + partner, org, authority, user = build_estate Generator.grant(user, Grant::SCOPE_PARTNER, partner.id.not_nil!.to_s, Permissions::Read).save! - Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Update).save! + Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Update).save! Grant.resolve(user.id.not_nil!, authority).should eq(Permissions::Read | Permissions::Update) end @@ -81,15 +81,15 @@ module PlaceOS::Model end it "ignores expired grants" do - _, client, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Manage, + _, org, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Manage, expires_at: Time.utc - 1.hour).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::None end it "honours a future expiry" do - _, client, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s, Permissions::Read, + _, org, authority, user = build_estate + Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Read, expires_at: Time.utc + 1.hour).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Read end @@ -100,8 +100,8 @@ module PlaceOS::Model end it "cascades on user delete" do - _, client, _, user = build_estate - grant = Generator.grant(user, Grant::SCOPE_CLIENT, client.id.not_nil!.to_s).save! + _, org, _, user = build_estate + grant = Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s).save! user.destroy Grant.find?(grant.id.not_nil!).should be_nil end diff --git a/spec/helper.cr b/spec/helper.cr index 32e8e6d4..5f5f7022 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -40,7 +40,7 @@ Spec.after_suite do PlaceOS::Model::GroupPlaylist, PlaceOS::Model::Group, PlaceOS::Model::Grant, - PlaceOS::Model::Client, + PlaceOS::Model::Organization, PlaceOS::Model::Partner, ].each(&.clear) end diff --git a/spec/organization_spec.cr b/spec/organization_spec.cr new file mode 100644 index 00000000..8d166f49 --- /dev/null +++ b/spec/organization_spec.cr @@ -0,0 +1,104 @@ +require "./helper" + +module PlaceOS::Model + describe Organization do + Spec.before_each do + ControlSystem.clear + Zone.clear + Authority.clear + Organization.clear + Partner.clear + end + + it "saves a partnered org with partner-pays default" do + partner = Generator.partner.save! + org = Generator.organization(partner: partner).save! + org.persisted?.should be_true + org.payer.should eq Organization::PAYER_PARTNER + org.self_managed?.should be_false + org.partner.try(&.id).should eq partner.id + end + + it "normalizes a org-owned org to pay for itself" do + org = Generator.organization.save! + org.partner_id.should be_nil + org.payer.should eq Organization::PAYER_ORGANIZATION + org.self_managed?.should be_true + end + + it "rejects an unknown payer" do + org = Generator.organization(partner: Generator.partner.save!, payer: "nobody") + org.valid?.should be_false + org.errors.map(&.field).should contain(:payer) + end + + it "scopes org name uniqueness to the partner" do + partner_a = Generator.partner.save! + partner_b = Generator.partner.save! + original = Generator.organization(partner: partner_a).save! + + # Same name under a different partner is fine + sibling = Generator.organization(partner: partner_b) + sibling.name = original.name + sibling.valid?.should be_true + + # Same name under the same partner is not + duplicate = Generator.organization(partner: partner_a) + duplicate.name = original.name + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + end + + it "rejects duplicate names among org-owned clients" do + original = Generator.organization.save! + duplicate = Generator.organization + duplicate.name = original.name + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + end + + it "owns authorities via authority.organization_id" do + org = Generator.organization.save! + authority = Generator.authority(domain: "org-spec.example.com") + authority.organization_id = org.id + authority.save! + + authority.organization.try(&.id).should eq org.id + org.authorities.to_a.map(&.id).should eq [authority.id] + end + + it "refuses to delete a org that still owns a domain" do + org = Generator.organization.save! + authority = Generator.authority(domain: "org-restrict.example.com") + authority.organization_id = org.id + authority.save! + + expect_raises(Exception, /foreign key/) { org.destroy } + Organization.find?(org.id.not_nil!).should_not be_nil + end + + it "rejects renaming a org onto a sibling's name" do + partner = Generator.partner.save! + original = Generator.organization(partner: partner).save! + sibling = Generator.organization(partner: partner).save! + + sibling.name = original.name + sibling.valid?.should be_false + sibling.errors.map(&.field).should contain(:name) + end + + it "records org ownership on zones and systems" do + org = Generator.organization.save! + zone = Generator.zone + zone.organization_id = org.id + zone.save! + + sys = Generator.control_system + sys.organization_id = org.id + sys.save! + + Zone.find!(zone.id.not_nil!).organization.try(&.id).should eq org.id + ControlSystem.find!(sys.id.not_nil!).organization.try(&.id).should eq org.id + end + end +end diff --git a/spec/partner_spec.cr b/spec/partner_spec.cr index 914502e2..492c98ab 100644 --- a/spec/partner_spec.cr +++ b/spec/partner_spec.cr @@ -6,7 +6,7 @@ module PlaceOS::Model ControlSystem.clear Zone.clear Authority.clear - Client.clear + Organization.clear Partner.clear end @@ -54,14 +54,14 @@ module PlaceOS::Model it "lists its clients" do partner = Generator.partner.save! - client = Generator.client(partner: partner).save! - Generator.client.save! - partner.clients.to_a.map(&.id).should eq [client.id] + org = Generator.organization(partner: partner).save! + Generator.organization.save! + partner.organizations.to_a.map(&.id).should eq [org.id] end it "refuses to delete a partner that still has clients" do partner = Generator.partner.save! - Generator.client(partner: partner).save! + Generator.organization(partner: partner).save! expect_raises(Exception, /foreign key/) { partner.destroy } Partner.find?(partner.id.not_nil!).should_not be_nil diff --git a/src/placeos-models/authority.cr b/src/placeos-models/authority.cr index 44c83572..6a90314b 100644 --- a/src/placeos-models/authority.cr +++ b/src/placeos-models/authority.cr @@ -28,9 +28,9 @@ module PlaceOS::Model attribute email_domains : Array(String) = [] of String - # PPT-526: the Client (customer organisation) that owns this domain. + # PPT-526: the Organization (customer organisation) that owns this domain. # Nullable while ownership backfill and provisioning are phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false macro finished # Ensure only the host is saved. @@ -77,9 +77,9 @@ module PlaceOS::Model Authority.where(domain: host).first? end - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end # Locates an authority by email domain diff --git a/src/placeos-models/broker.cr b/src/placeos-models/broker.cr index 0677195a..f643fd8e 100644 --- a/src/placeos-models/broker.cr +++ b/src/placeos-models/broker.cr @@ -33,14 +33,14 @@ module PlaceOS::Model # Matches will be replaced with a hmac_256(secret, match). attribute filters : Array(String) = -> { [] of String } - # PPT-526: the Client (customer organisation) that owns this broker. + # PPT-526: the Organization (customer organisation) that owns this broker. # NULL = cluster-level infrastructure (the default; the backfill never # assigns brokers). Nullable while query enforcement is phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end # Validation diff --git a/src/placeos-models/control_system.cr b/src/placeos-models/control_system.cr index a18c947c..f99f5a6a 100644 --- a/src/placeos-models/control_system.cr +++ b/src/placeos-models/control_system.cr @@ -42,13 +42,13 @@ module PlaceOS::Model # Array of security group ids for room access attribute security_groups : Array(String) = -> { [] of String } - # PPT-526: the Client (customer organisation) that owns this system. + # PPT-526: the Organization (customer organisation) that owns this system. # Nullable while ownership backfill and query enforcement are phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end attribute timezone : Time::Location?, converter: Time::Location::Converter, es_type: "text" diff --git a/src/placeos-models/edge.cr b/src/placeos-models/edge.cr index 3909ff8d..d703d1bb 100644 --- a/src/placeos-models/edge.cr +++ b/src/placeos-models/edge.cr @@ -20,13 +20,13 @@ module PlaceOS::Model attribute last_seen : Time?, converter: Time::EpochConverterOptional, mass_assignment: false attribute online : Bool = false, mass_assignment: false - # PPT-526: the Client (customer organisation) that owns this edge node. + # PPT-526: the Organization (customer organisation) that owns this edge node. # Nullable while ownership backfill and query enforcement are phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end @[JSON::Field(ignore: true)] diff --git a/src/placeos-models/grant.cr b/src/placeos-models/grant.cr index 66243003..459830c4 100644 --- a/src/placeos-models/grant.cr +++ b/src/placeos-models/grant.cr @@ -4,30 +4,30 @@ require "uuid/json" require "./base/model" require "./permissions" require "./authority" -require "./client" +require "./organization" require "./partner" module PlaceOS::Model # PPT-526 Stage 3: a cross-tenant authorization grant. # - # Gives a user a `Permissions` bitmask over one scope — a Partner, a Client, + # Gives a user a `Permissions` bitmask over one scope — a Partner, an Organization, # or an Authority. Authorization resolves by walking UP from the touched - # resource: authority -> its client -> that client's partner. A grant at - # partner scope therefore covers every one of that partner's clients, current - # and future (the NTT-over-its-clients case); a grant at client scope covers - # that client's authorities; a grant at authority scope is a single domain. + # resource: authority -> its organization -> that organization's partner. A grant at + # partner scope therefore covers every one of that partner's organizations, current + # and future (the NTT-over-its-clients case); a grant at organization scope covers + # that organization's authorities; a grant at authority scope is a single domain. # - # `scope_id` is polymorphic (a Partner/Client UUID as text, or an Authority + # `scope_id` is polymorphic (a Partner/Organization UUID as text, or an Authority # TEXT id) so it carries no FK; a grant naming a deleted scope is inert. class Grant < ::PgORM::Base include PgORM::Timestamps table :grants - SCOPE_PARTNER = "partner" - SCOPE_CLIENT = "client" - SCOPE_AUTHORITY = "authority" - SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_CLIENT, SCOPE_AUTHORITY] + SCOPE_PARTNER = "partner" + SCOPE_ORGANIZATION = "organization" + SCOPE_AUTHORITY = "authority" + SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_ORGANIZATION, SCOPE_AUTHORITY] default_primary_key id : UUID, autogenerated: true @@ -78,7 +78,7 @@ module PlaceOS::Model # ------------------------------------------------------------------ # Effective permissions a user holds over `authority`, resolved by walking - # up authority -> client -> partner and OR-ing every live grant on the + # up authority -> organization -> partner and OR-ing every live grant on the # chain. Explicit grants only; the management-partner "support sees all" # rule is applied by the enforcement layer, not here. def self.resolve(user_id : String, authority : Authority, at : Time = Time.utc) : Permissions @@ -93,13 +93,13 @@ module PlaceOS::Model end # The (scope_type, scope_id) pairs to check for a given authority, from - # most specific to least: the authority itself, its client, that client's + # most specific to least: the authority itself, its organization, that organization's # partner. Missing links are simply absent. def self.scope_chain(authority : Authority) : Array({String, String}) chain = [{SCOPE_AUTHORITY, authority.id.to_s}] - if (client_id = authority.client_id) - chain << {SCOPE_CLIENT, client_id.to_s} - if (client = Client.find?(client_id)) && (partner_id = client.partner_id) + if (organization_id = authority.organization_id) + chain << {SCOPE_ORGANIZATION, organization_id.to_s} + if (organization = Organization.find?(organization_id)) && (partner_id = organization.partner_id) chain << {SCOPE_PARTNER, partner_id.to_s} end end @@ -127,7 +127,7 @@ module PlaceOS::Model Grant.where(user_id: user_id).select(&.live?) end - # Everyone granted access to a specific scope (partner/client admin console). + # Everyone granted access to a specific scope (partner/organization admin console). def self.for_scope(scope_type : String, scope_id : String) : Array(Grant) Grant.where(scope_type: scope_type, scope_id: scope_id).to_a end diff --git a/src/placeos-models/module.cr b/src/placeos-models/module.cr index 2fe0c758..939176ea 100644 --- a/src/placeos-models/module.cr +++ b/src/placeos-models/module.cr @@ -48,15 +48,15 @@ module PlaceOS::Model attribute ignore_connected : Bool = false attribute ignore_startstop : Bool = false - # PPT-526: the Client (customer organisation) that owns this module. + # PPT-526: the Organization (customer organisation) that owns this module. # Nullable while ownership backfill and query enforcement are phased in; - # shared device/service modules referenced by several clients' systems + # shared device/service modules referenced by several organizations' systems # stay NULL until a sharing policy is decided. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end # Runtime Error Indicators diff --git a/src/placeos-models/client.cr b/src/placeos-models/organization.cr similarity index 57% rename from src/placeos-models/client.cr rename to src/placeos-models/organization.cr index f211c448..5294211b 100644 --- a/src/placeos-models/client.cr +++ b/src/placeos-models/organization.cr @@ -6,22 +6,23 @@ require "./partner" module PlaceOS::Model # A customer organisation (e.g. UCLA, Acadian) — the estate owner in the - # PPT-526 hierarchy: Partner -> Client -> Authority (domain) -> ... + # PPT-526 hierarchy: Partner -> Organization -> Authority (domain) -> ... + # This is the entity the multi-tenancy-template calls "Organization". # # `partner_id` NULL means client-owned: the customer signed up and manages # itself without an integrator. `payer` records who receives invoices for - # this client: the partner (integrator pays us and re-bills — the default - # channel model) or the client directly. A client without a partner can - # only pay for itself; `normalize_payer` keeps that invariant ahead of the - # DB CHECK constraint. - class Client < ::PgORM::Base + # this organization: the partner (integrator pays us and re-bills — the + # default channel model) or the organization directly. An organization + # without a partner can only pay for itself; `normalize_payer` keeps that + # invariant ahead of the DB CHECK constraint. + class Organization < ::PgORM::Base include PgORM::Timestamps - table :clients + table :organizations - PAYER_PARTNER = "partner" - PAYER_CLIENT = "client" - PAYERS = [PAYER_PARTNER, PAYER_CLIENT] + PAYER_PARTNER = "partner" + PAYER_ORGANIZATION = "organization" + PAYERS = [PAYER_PARTNER, PAYER_ORGANIZATION] default_primary_key id : UUID, autogenerated: true @@ -37,35 +38,35 @@ module PlaceOS::Model validates :name, presence: true - validate ->(this : Client) { + validate ->(this : Organization) { unless PAYERS.includes?(this.payer) this.validation_error(:payer, "must be one of #{PAYERS.join(", ")}") end } - validate ->(this : Client) { + validate ->(this : Organization) { # Friendlier error ahead of the DB's partial unique indexes: names are - # unique within a partner's book, and unique among client-owned clients. + # unique within a partner's book, and unique among client-owned orgs. name = this.name return if name.nil? || name.empty? - existing = Client.where(partner_id: this.partner_id, name: name).first? + existing = Organization.where(partner_id: this.partner_id, name: name).first? return if existing.nil? return if this.persisted? && existing.id == this.id - this.validation_error(:name, "a client with this name already exists") + this.validation_error(:name, "an organization with this name already exists") } protected def normalize_payer - self.payer = PAYER_CLIENT if self.partner_id.nil? + self.payer = PAYER_ORGANIZATION if self.partner_id.nil? end - # True when the customer manages itself without an integrator. - def client_owned? : Bool + # True when the customer manages itself without an integrator (no partner). + def self_managed? : Bool self.partner_id.nil? end - # Domains (authorities) owned by this client. + # Domains (authorities) owned by this organization. def authorities - Authority.where(client_id: self.id) + Authority.where(organization_id: self.id) end end end diff --git a/src/placeos-models/partner.cr b/src/placeos-models/partner.cr index 25927ae7..82325f08 100644 --- a/src/placeos-models/partner.cr +++ b/src/placeos-models/partner.cr @@ -64,9 +64,9 @@ module PlaceOS::Model Partner.where(parent_id: self.id) end - # Clients under this partner's book of business. - def clients - Client.where(partner_id: self.id) + # Organizations under this partner's book of business. + def organizations + Organization.where(partner_id: self.id) end end end diff --git a/src/placeos-models/trigger.cr b/src/placeos-models/trigger.cr index bee72d9c..21e3bc10 100644 --- a/src/placeos-models/trigger.cr +++ b/src/placeos-models/trigger.cr @@ -27,14 +27,14 @@ module PlaceOS::Model METHODS = %w(GET POST PUT PATCH DELETE) attribute supported_methods : Array(String) = ["POST"] - # PPT-526: the Client (customer organisation) that owns this trigger + # PPT-526: the Organization (customer organisation) that owns this trigger # definition. NULL = shared/cluster-wide definition. Nullable while # ownership backfill and query enforcement are phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end # Association diff --git a/src/placeos-models/zone.cr b/src/placeos-models/zone.cr index 24124ed5..a7ad383f 100644 --- a/src/placeos-models/zone.cr +++ b/src/placeos-models/zone.cr @@ -49,13 +49,13 @@ module PlaceOS::Model attribute images : Array(String) = [] of String attribute playlists : Array(String) = [] of String, es_type: "keyword" - # PPT-526: the Client (customer organisation) that owns this zone. + # PPT-526: the Organization (customer organisation) that owns this zone. # Nullable while ownership backfill and query enforcement are phased in. - attribute client_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Client, when ownership has been assigned. - def client : PlaceOS::Model::Client? - self.client_id.try { |id| PlaceOS::Model::Client.find?(id) } + # The owning Organization, when ownership has been assigned. + def organization : PlaceOS::Model::Organization? + self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } end attribute place_id : String? From df827c7f65adbacd377209bf582f07fd3e58593c Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 11:57:55 +1100 Subject: [PATCH 4/6] refactor: PPT-526 spell Organisation and renumber the tenancy migrations above master Cam's call on 7 Oct 2026: the entity is Organisation everywhere (table organisations, column organisation_id, grant scope organisation). The three migrations move from 20260818/19 ids to 20261007100500000, 20261007100600000 and 20261007100700000 so they sort after 20261001100500000, the newest migration on master. --- ...0500000_add_partners_and_organizations.sql | 211 ------------------ ...0500000_add_partners_and_organisations.sql | 211 ++++++++++++++++++ ...00000_backfill_organisation_ownership.sql} | 110 ++++----- ...s.sql => 20261007100700000_add_grants.sql} | 12 +- spec/generator.cr | 4 +- spec/grant_spec.cr | 28 +-- spec/helper.cr | 2 +- ...anization_spec.cr => organisation_spec.cr} | 52 ++--- spec/partner_spec.cr | 10 +- src/placeos-models/authority.cr | 10 +- src/placeos-models/broker.cr | 10 +- src/placeos-models/control_system.cr | 10 +- src/placeos-models/edge.cr | 10 +- src/placeos-models/grant.cr | 30 +-- src/placeos-models/module.cr | 12 +- .../{organization.cr => organisation.cr} | 30 +-- src/placeos-models/partner.cr | 6 +- src/placeos-models/trigger.cr | 10 +- src/placeos-models/zone.cr | 10 +- 19 files changed, 389 insertions(+), 389 deletions(-) delete mode 100644 migration/db/migrations/20260818100500000_add_partners_and_organizations.sql create mode 100644 migration/db/migrations/20261007100500000_add_partners_and_organisations.sql rename migration/db/migrations/{20260818100600000_backfill_organization_ownership.sql => 20261007100600000_backfill_organisation_ownership.sql} (67%) rename migration/db/migrations/{20260819100500000_add_grants.sql => 20261007100700000_add_grants.sql} (87%) rename spec/{organization_spec.cr => organisation_spec.cr} (62%) rename src/placeos-models/{organization.cr => organisation.cr} (68%) diff --git a/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql b/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql deleted file mode 100644 index 524e9084..00000000 --- a/migration/db/migrations/20260818100500000_add_partners_and_organizations.sql +++ /dev/null @@ -1,211 +0,0 @@ --- +micrate Up --- SQL in section 'Up' is executed when this migration is applied - --- MERGE-TIME CHECK: micrate refuses out-of-order version ids (see models#323). --- If any later-dated migration lands on master before this merges, renumber --- this file AND 20260818100600000 above it (preserving schema-before-backfill --- order). Safe: neither has been applied anywhere and the backfill is --- idempotent. - --- --------------------------------------------------------------------------- --- PPT-526: the Partner → Organization → Authority(domain) hierarchy. --- --- partners: integrators/resellers (e.g. NTT). `management = true` marks the --- platform operator's own organisation (PlaceOS staff) — the "management --- organization" concept from the ticket, done at the partner level. `parent_id` --- reserves a 2-tier channel (distributor → reseller) without any UI or --- enforcement yet. --- --- organizations: the customer organisation (e.g. UCLA, Acadian). `partner_id` NULL --- means organization-owned (self-managed, no integrator). `payer` records who is --- invoiced: 'partner' (integrator pays us and re-bills, the default channel --- model) or 'organization' (direct). A organization without a partner can only pay for --- itself — enforced by CHECK. --- --- Every delete path is RESTRICT: removing a partner/organization must go through an --- orchestrated teardown (the PPT-1203 pattern), never a silent DB cascade — --- model-level cleanup callbacks do not fire on DB cascades. --- --------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS "partners"( - id UUID PRIMARY KEY DEFAULT uuidv7(), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - management BOOLEAN NOT NULL DEFAULT false, - parent_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS partners_name_unique - ON "partners" USING BTREE (name); -CREATE INDEX IF NOT EXISTS partners_parent_id_index - ON "partners" USING BTREE (parent_id); - -CREATE TABLE IF NOT EXISTS "organizations"( - id UUID PRIMARY KEY DEFAULT uuidv7(), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - partner_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, - payer TEXT NOT NULL DEFAULT 'partner' - CHECK (payer IN ('partner', 'organization')), - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - CONSTRAINT organizations_payer_requires_partner - CHECK (partner_id IS NOT NULL OR payer = 'organization') -); - --- Organization names are unique within a partner's book of business, and unique --- among organization-owned organizations. Two partial indexes because UNIQUE treats --- NULLs as distinct — a plain UNIQUE (partner_id, name) would let every --- organization-owned organization share a name. -CREATE UNIQUE INDEX IF NOT EXISTS organizations_name_unique_per_partner - ON "organizations" USING BTREE (partner_id, name) WHERE partner_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS organizations_name_unique_owned - ON "organizations" USING BTREE (name) WHERE partner_id IS NULL; -CREATE INDEX IF NOT EXISTS organizations_partner_id_index - ON "organizations" USING BTREE (partner_id); - --- --------------------------------------------------------------------------- --- Authority → Organization ownership. Nullable at the DB level for backwards --- compatibility (init seeds authorities before any organization exists); the --- provisioning flow and backfill populate it. RESTRICT so a organization cannot be --- deleted while it still owns domains. --- --------------------------------------------------------------------------- -ALTER TABLE "authority" - ADD COLUMN IF NOT EXISTS organization_id UUID; - -ALTER TABLE ONLY "authority" - DROP CONSTRAINT IF EXISTS authority_organization_id_fkey; -ALTER TABLE ONLY "authority" - ADD CONSTRAINT authority_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; - -CREATE INDEX IF NOT EXISTS authority_organization_id_index - ON "authority" USING BTREE (organization_id); - --- The domain column has only ever had model-level uniqueness (a plain BTREE --- index) — a concurrent-signup race can create duplicate authorities. Make it --- a hard guarantee; self-service signup depends on it. --- --- Quarantine first: exact-duplicate domains left behind by that race would --- make the unique index unbuildable and (because micrate autocommits each --- statement) leave this migration half-applied. Keep the oldest row per --- domain and rename the rest so they stop answering for the domain — --- find_by_domain picks an arbitrary row across duplicates today, so this --- cannot break a reliably-working login. Idempotent: renamed rows no longer --- collide. Renamed rows are for an operator to merge or delete. -UPDATE "authority" a -SET domain = a.domain || '.duplicate.' || a.id -WHERE EXISTS ( - SELECT 1 FROM "authority" b - WHERE b.domain = a.domain - AND (b.created_at, b.id) < (a.created_at, a.id) -); - -CREATE UNIQUE INDEX IF NOT EXISTS authority_domain_unique - ON "authority" USING BTREE (domain); - --- --------------------------------------------------------------------------- --- Organization ownership columns on the (previously cluster-global) control plane. --- Nullable: populated by the backfill migration where inference is --- unambiguous, and by the provisioning flow for everything new. Query-path --- enforcement is phased in separately (rest-api); these columns are the --- ground truth it will filter on. --- --------------------------------------------------------------------------- -ALTER TABLE "zone" ADD COLUMN IF NOT EXISTS organization_id UUID; -ALTER TABLE "sys" ADD COLUMN IF NOT EXISTS organization_id UUID; -ALTER TABLE "mod" ADD COLUMN IF NOT EXISTS organization_id UUID; -ALTER TABLE "trigger" ADD COLUMN IF NOT EXISTS organization_id UUID; -ALTER TABLE "edge" ADD COLUMN IF NOT EXISTS organization_id UUID; -ALTER TABLE "broker" ADD COLUMN IF NOT EXISTS organization_id UUID; - -ALTER TABLE ONLY "zone" - DROP CONSTRAINT IF EXISTS zone_organization_id_fkey; -ALTER TABLE ONLY "zone" - ADD CONSTRAINT zone_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "sys" - DROP CONSTRAINT IF EXISTS sys_organization_id_fkey; -ALTER TABLE ONLY "sys" - ADD CONSTRAINT sys_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "mod" - DROP CONSTRAINT IF EXISTS mod_organization_id_fkey; -ALTER TABLE ONLY "mod" - ADD CONSTRAINT mod_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "trigger" - DROP CONSTRAINT IF EXISTS trigger_organization_id_fkey; -ALTER TABLE ONLY "trigger" - ADD CONSTRAINT trigger_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "edge" - DROP CONSTRAINT IF EXISTS edge_organization_id_fkey; -ALTER TABLE ONLY "edge" - ADD CONSTRAINT edge_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; -ALTER TABLE ONLY "broker" - DROP CONSTRAINT IF EXISTS broker_organization_id_fkey; -ALTER TABLE ONLY "broker" - ADD CONSTRAINT broker_organization_id_fkey - FOREIGN KEY (organization_id) REFERENCES "organizations"(id) ON DELETE RESTRICT; - -CREATE INDEX IF NOT EXISTS zone_organization_id_index ON "zone" USING BTREE (organization_id); -CREATE INDEX IF NOT EXISTS sys_organization_id_index ON "sys" USING BTREE (organization_id); -CREATE INDEX IF NOT EXISTS mod_organization_id_index ON "mod" USING BTREE (organization_id); -CREATE INDEX IF NOT EXISTS trigger_organization_id_index ON "trigger" USING BTREE (organization_id); -CREATE INDEX IF NOT EXISTS edge_organization_id_index ON "edge" USING BTREE (organization_id); -CREATE INDEX IF NOT EXISTS broker_organization_id_index ON "broker" USING BTREE (organization_id); - --- Per-organization name uniqueness for the estate. Dormant while organization_id is NULL --- (UNIQUE treats NULLs as distinct) and strictly weaker than the current --- model-level global uniqueness, so it cannot conflict with existing data. --- When query enforcement lands, the model-level checks flip from global to --- organization-scoped and these indexes become the hard guarantee. -CREATE UNIQUE INDEX IF NOT EXISTS zone_organization_id_name_unique - ON "zone" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS sys_organization_id_name_unique - ON "sys" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS edge_organization_id_name_unique - ON "edge" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS broker_organization_id_name_unique - ON "broker" USING BTREE (organization_id, name) WHERE organization_id IS NOT NULL; - --- +micrate Down --- SQL section 'Down' is executed when this migration is rolled back - -DROP INDEX IF EXISTS broker_organization_id_name_unique; -DROP INDEX IF EXISTS edge_organization_id_name_unique; -DROP INDEX IF EXISTS sys_organization_id_name_unique; -DROP INDEX IF EXISTS zone_organization_id_name_unique; - -DROP INDEX IF EXISTS broker_organization_id_index; -DROP INDEX IF EXISTS edge_organization_id_index; -DROP INDEX IF EXISTS trigger_organization_id_index; -DROP INDEX IF EXISTS mod_organization_id_index; -DROP INDEX IF EXISTS sys_organization_id_index; -DROP INDEX IF EXISTS zone_organization_id_index; - -ALTER TABLE ONLY "broker" DROP CONSTRAINT IF EXISTS broker_organization_id_fkey; -ALTER TABLE ONLY "edge" DROP CONSTRAINT IF EXISTS edge_organization_id_fkey; -ALTER TABLE ONLY "trigger" DROP CONSTRAINT IF EXISTS trigger_organization_id_fkey; -ALTER TABLE ONLY "mod" DROP CONSTRAINT IF EXISTS mod_organization_id_fkey; -ALTER TABLE ONLY "sys" DROP CONSTRAINT IF EXISTS sys_organization_id_fkey; -ALTER TABLE ONLY "zone" DROP CONSTRAINT IF EXISTS zone_organization_id_fkey; - -ALTER TABLE "broker" DROP COLUMN IF EXISTS organization_id; -ALTER TABLE "edge" DROP COLUMN IF EXISTS organization_id; -ALTER TABLE "trigger" DROP COLUMN IF EXISTS organization_id; -ALTER TABLE "mod" DROP COLUMN IF EXISTS organization_id; -ALTER TABLE "sys" DROP COLUMN IF EXISTS organization_id; -ALTER TABLE "zone" DROP COLUMN IF EXISTS organization_id; - -DROP INDEX IF EXISTS authority_domain_unique; -DROP INDEX IF EXISTS authority_organization_id_index; -ALTER TABLE ONLY "authority" DROP CONSTRAINT IF EXISTS authority_organization_id_fkey; -ALTER TABLE "authority" DROP COLUMN IF EXISTS organization_id; - -DROP TABLE IF EXISTS "organizations"; -DROP TABLE IF EXISTS "partners"; diff --git a/migration/db/migrations/20261007100500000_add_partners_and_organisations.sql b/migration/db/migrations/20261007100500000_add_partners_and_organisations.sql new file mode 100644 index 00000000..cefe2179 --- /dev/null +++ b/migration/db/migrations/20261007100500000_add_partners_and_organisations.sql @@ -0,0 +1,211 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- MERGE-TIME CHECK: micrate refuses out-of-order version ids (see models#323). +-- If any later-dated migration lands on master before this merges, renumber +-- this file AND 20261007100600000 above it (preserving schema-before-backfill +-- order). Safe: neither has been applied anywhere and the backfill is +-- idempotent. + +-- --------------------------------------------------------------------------- +-- PPT-526: the Partner → Organisation → Authority(domain) hierarchy. +-- +-- partners: integrators/resellers (e.g. NTT). `management = true` marks the +-- platform operator's own organisation (PlaceOS staff) — the "management +-- organisation" concept from the ticket, done at the partner level. `parent_id` +-- reserves a 2-tier channel (distributor → reseller) without any UI or +-- enforcement yet. +-- +-- organisations: the customer organisation (e.g. UCLA, Acadian). `partner_id` NULL +-- means organisation-owned (self-managed, no integrator). `payer` records who is +-- invoiced: 'partner' (integrator pays us and re-bills, the default channel +-- model) or 'organisation' (direct). A organisation without a partner can only pay for +-- itself — enforced by CHECK. +-- +-- Every delete path is RESTRICT: removing a partner/organisation must go through an +-- orchestrated teardown (the PPT-1203 pattern), never a silent DB cascade — +-- model-level cleanup callbacks do not fire on DB cascades. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS "partners"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + management BOOLEAN NOT NULL DEFAULT false, + parent_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS partners_name_unique + ON "partners" USING BTREE (name); +CREATE INDEX IF NOT EXISTS partners_parent_id_index + ON "partners" USING BTREE (parent_id); + +CREATE TABLE IF NOT EXISTS "organisations"( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + partner_id UUID REFERENCES "partners"(id) ON DELETE RESTRICT, + payer TEXT NOT NULL DEFAULT 'partner' + CHECK (payer IN ('partner', 'organisation')), + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT organisations_payer_requires_partner + CHECK (partner_id IS NOT NULL OR payer = 'organisation') +); + +-- Organisation names are unique within a partner's book of business, and unique +-- among organisation-owned organisations. Two partial indexes because UNIQUE treats +-- NULLs as distinct — a plain UNIQUE (partner_id, name) would let every +-- organisation-owned organisation share a name. +CREATE UNIQUE INDEX IF NOT EXISTS organisations_name_unique_per_partner + ON "organisations" USING BTREE (partner_id, name) WHERE partner_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS organisations_name_unique_owned + ON "organisations" USING BTREE (name) WHERE partner_id IS NULL; +CREATE INDEX IF NOT EXISTS organisations_partner_id_index + ON "organisations" USING BTREE (partner_id); + +-- --------------------------------------------------------------------------- +-- Authority → Organisation ownership. Nullable at the DB level for backwards +-- compatibility (init seeds authorities before any organisation exists); the +-- provisioning flow and backfill populate it. RESTRICT so a organisation cannot be +-- deleted while it still owns domains. +-- --------------------------------------------------------------------------- +ALTER TABLE "authority" + ADD COLUMN IF NOT EXISTS organisation_id UUID; + +ALTER TABLE ONLY "authority" + DROP CONSTRAINT IF EXISTS authority_organisation_id_fkey; +ALTER TABLE ONLY "authority" + ADD CONSTRAINT authority_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS authority_organisation_id_index + ON "authority" USING BTREE (organisation_id); + +-- The domain column has only ever had model-level uniqueness (a plain BTREE +-- index) — a concurrent-signup race can create duplicate authorities. Make it +-- a hard guarantee; self-service signup depends on it. +-- +-- Quarantine first: exact-duplicate domains left behind by that race would +-- make the unique index unbuildable and (because micrate autocommits each +-- statement) leave this migration half-applied. Keep the oldest row per +-- domain and rename the rest so they stop answering for the domain — +-- find_by_domain picks an arbitrary row across duplicates today, so this +-- cannot break a reliably-working login. Idempotent: renamed rows no longer +-- collide. Renamed rows are for an operator to merge or delete. +UPDATE "authority" a +SET domain = a.domain || '.duplicate.' || a.id +WHERE EXISTS ( + SELECT 1 FROM "authority" b + WHERE b.domain = a.domain + AND (b.created_at, b.id) < (a.created_at, a.id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS authority_domain_unique + ON "authority" USING BTREE (domain); + +-- --------------------------------------------------------------------------- +-- Organisation ownership columns on the (previously cluster-global) control plane. +-- Nullable: populated by the backfill migration where inference is +-- unambiguous, and by the provisioning flow for everything new. Query-path +-- enforcement is phased in separately (rest-api); these columns are the +-- ground truth it will filter on. +-- --------------------------------------------------------------------------- +ALTER TABLE "zone" ADD COLUMN IF NOT EXISTS organisation_id UUID; +ALTER TABLE "sys" ADD COLUMN IF NOT EXISTS organisation_id UUID; +ALTER TABLE "mod" ADD COLUMN IF NOT EXISTS organisation_id UUID; +ALTER TABLE "trigger" ADD COLUMN IF NOT EXISTS organisation_id UUID; +ALTER TABLE "edge" ADD COLUMN IF NOT EXISTS organisation_id UUID; +ALTER TABLE "broker" ADD COLUMN IF NOT EXISTS organisation_id UUID; + +ALTER TABLE ONLY "zone" + DROP CONSTRAINT IF EXISTS zone_organisation_id_fkey; +ALTER TABLE ONLY "zone" + ADD CONSTRAINT zone_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "sys" + DROP CONSTRAINT IF EXISTS sys_organisation_id_fkey; +ALTER TABLE ONLY "sys" + ADD CONSTRAINT sys_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "mod" + DROP CONSTRAINT IF EXISTS mod_organisation_id_fkey; +ALTER TABLE ONLY "mod" + ADD CONSTRAINT mod_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "trigger" + DROP CONSTRAINT IF EXISTS trigger_organisation_id_fkey; +ALTER TABLE ONLY "trigger" + ADD CONSTRAINT trigger_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "edge" + DROP CONSTRAINT IF EXISTS edge_organisation_id_fkey; +ALTER TABLE ONLY "edge" + ADD CONSTRAINT edge_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; +ALTER TABLE ONLY "broker" + DROP CONSTRAINT IF EXISTS broker_organisation_id_fkey; +ALTER TABLE ONLY "broker" + ADD CONSTRAINT broker_organisation_id_fkey + FOREIGN KEY (organisation_id) REFERENCES "organisations"(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS zone_organisation_id_index ON "zone" USING BTREE (organisation_id); +CREATE INDEX IF NOT EXISTS sys_organisation_id_index ON "sys" USING BTREE (organisation_id); +CREATE INDEX IF NOT EXISTS mod_organisation_id_index ON "mod" USING BTREE (organisation_id); +CREATE INDEX IF NOT EXISTS trigger_organisation_id_index ON "trigger" USING BTREE (organisation_id); +CREATE INDEX IF NOT EXISTS edge_organisation_id_index ON "edge" USING BTREE (organisation_id); +CREATE INDEX IF NOT EXISTS broker_organisation_id_index ON "broker" USING BTREE (organisation_id); + +-- Per-organisation name uniqueness for the estate. Dormant while organisation_id is NULL +-- (UNIQUE treats NULLs as distinct) and strictly weaker than the current +-- model-level global uniqueness, so it cannot conflict with existing data. +-- When query enforcement lands, the model-level checks flip from global to +-- organisation-scoped and these indexes become the hard guarantee. +CREATE UNIQUE INDEX IF NOT EXISTS zone_organisation_id_name_unique + ON "zone" USING BTREE (organisation_id, name) WHERE organisation_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS sys_organisation_id_name_unique + ON "sys" USING BTREE (organisation_id, name) WHERE organisation_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS edge_organisation_id_name_unique + ON "edge" USING BTREE (organisation_id, name) WHERE organisation_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS broker_organisation_id_name_unique + ON "broker" USING BTREE (organisation_id, name) WHERE organisation_id IS NOT NULL; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +DROP INDEX IF EXISTS broker_organisation_id_name_unique; +DROP INDEX IF EXISTS edge_organisation_id_name_unique; +DROP INDEX IF EXISTS sys_organisation_id_name_unique; +DROP INDEX IF EXISTS zone_organisation_id_name_unique; + +DROP INDEX IF EXISTS broker_organisation_id_index; +DROP INDEX IF EXISTS edge_organisation_id_index; +DROP INDEX IF EXISTS trigger_organisation_id_index; +DROP INDEX IF EXISTS mod_organisation_id_index; +DROP INDEX IF EXISTS sys_organisation_id_index; +DROP INDEX IF EXISTS zone_organisation_id_index; + +ALTER TABLE ONLY "broker" DROP CONSTRAINT IF EXISTS broker_organisation_id_fkey; +ALTER TABLE ONLY "edge" DROP CONSTRAINT IF EXISTS edge_organisation_id_fkey; +ALTER TABLE ONLY "trigger" DROP CONSTRAINT IF EXISTS trigger_organisation_id_fkey; +ALTER TABLE ONLY "mod" DROP CONSTRAINT IF EXISTS mod_organisation_id_fkey; +ALTER TABLE ONLY "sys" DROP CONSTRAINT IF EXISTS sys_organisation_id_fkey; +ALTER TABLE ONLY "zone" DROP CONSTRAINT IF EXISTS zone_organisation_id_fkey; + +ALTER TABLE "broker" DROP COLUMN IF EXISTS organisation_id; +ALTER TABLE "edge" DROP COLUMN IF EXISTS organisation_id; +ALTER TABLE "trigger" DROP COLUMN IF EXISTS organisation_id; +ALTER TABLE "mod" DROP COLUMN IF EXISTS organisation_id; +ALTER TABLE "sys" DROP COLUMN IF EXISTS organisation_id; +ALTER TABLE "zone" DROP COLUMN IF EXISTS organisation_id; + +DROP INDEX IF EXISTS authority_domain_unique; +DROP INDEX IF EXISTS authority_organisation_id_index; +ALTER TABLE ONLY "authority" DROP CONSTRAINT IF EXISTS authority_organisation_id_fkey; +ALTER TABLE "authority" DROP COLUMN IF EXISTS organisation_id; + +DROP TABLE IF EXISTS "organisations"; +DROP TABLE IF EXISTS "partners"; diff --git a/migration/db/migrations/20260818100600000_backfill_organization_ownership.sql b/migration/db/migrations/20261007100600000_backfill_organisation_ownership.sql similarity index 67% rename from migration/db/migrations/20260818100600000_backfill_organization_ownership.sql rename to migration/db/migrations/20261007100600000_backfill_organisation_ownership.sql index 35d7ed60..904715a2 100644 --- a/migration/db/migrations/20260818100600000_backfill_organization_ownership.sql +++ b/migration/db/migrations/20261007100600000_backfill_organisation_ownership.sql @@ -2,10 +2,10 @@ -- SQL in section 'Up' is executed when this migration is applied -- --------------------------------------------------------------------------- --- PPT-526: backfill Partner/Organization ownership for existing data. +-- PPT-526: backfill Partner/Organisation ownership for existing data. -- -- Idempotent and additive: every statement only creates missing rows or fills --- NULL organization_id columns, so it is safe to re-apply and safe on databases +-- NULL organisation_id columns, so it is safe to re-apply and safe on databases -- where operators have already assigned ownership by hand. Where inference is -- ambiguous the row is deliberately LEFT NULL for a human to resolve (the -- org_zone convention is known to be non-exclusive and sometimes absent). @@ -13,15 +13,15 @@ -- What it does: -- 1. Ensures a management partner exists ("PlaceOS" — the platform -- operator's own organisation). --- 2. Creates one organization-owned Organization per Authority that has none, named +-- 2. Creates one organisation-owned Organisation per Authority that has none, named -- " ()" (domain uniqueness makes this collision -- free), and points the authority at it. Estates that span several --- authorities under one real-world organization (e.g. NTT's two domains) are --- merged later by re-pointing authority.organization_id by hand or API. --- 3. Zones: a zone belongs to the organization of the authority whose +-- authorities under one real-world organisation (e.g. NTT's two domains) are +-- merged later by re-pointing authority.organisation_id by hand or API. +-- 3. Zones: a zone belongs to the organisation of the authority whose -- config->>'org_zone' names the zone's ROOT zone — only when exactly one --- organization is implied (shared org zones stay NULL). --- 4. Systems: the single distinct organization of their zones, if unambiguous. +-- organisation is implied (shared org zones stay NULL). +-- 4. Systems: the single distinct organisation of their zones, if unambiguous. -- 5. Modules: logic modules via their control system; shared device/service -- modules via the systems that reference them, if unambiguous. -- 6. Trigger definitions: via their control system when system-scoped; @@ -38,20 +38,20 @@ SELECT 'PlaceOS', 'Platform operator (management partner)', true, now(), now() WHERE NOT EXISTS (SELECT 1 FROM "partners" WHERE management = true) ON CONFLICT DO NOTHING; --- 2. One organization-owned Organization per orphan Authority +-- 2. One organisation-owned Organisation per orphan Authority -- +micrate StatementBegin DO $$ DECLARE auth RECORD; new_org UUID; BEGIN - FOR auth IN SELECT id, name, domain FROM "authority" WHERE organization_id IS NULL LOOP + FOR auth IN SELECT id, name, domain FROM "authority" WHERE organisation_id IS NULL LOOP new_org := NULL; - INSERT INTO "organizations" (name, description, partner_id, payer, created_at, updated_at) + INSERT INTO "organisations" (name, description, partner_id, payer, created_at, updated_at) VALUES ( COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')', - 'Auto-created from authority ' || auth.id || ' during PPT-526 organization backfill', - NULL, 'organization', now(), now() + 'Auto-created from authority ' || auth.id || ' during PPT-526 organisation backfill', + NULL, 'organisation', now(), now() ) ON CONFLICT DO NOTHING RETURNING id INTO new_org; @@ -59,13 +59,13 @@ BEGIN -- If the name already existed (re-run against a partially-backfilled DB), -- reuse the existing row rather than leaving the authority orphaned. IF new_org IS NULL THEN - SELECT id INTO new_org FROM "organizations" + SELECT id INTO new_org FROM "organisations" WHERE partner_id IS NULL AND name = COALESCE(NULLIF(auth.name, ''), auth.domain) || ' (' || auth.domain || ')'; END IF; IF new_org IS NOT NULL THEN - UPDATE "authority" SET organization_id = new_org WHERE id = auth.id; + UPDATE "authority" SET organisation_id = new_org WHERE id = auth.id; END IF; END LOOP; END $$; @@ -75,9 +75,9 @@ END $$; -- UNION (not UNION ALL) so accidental cycles in zone parentage terminate. -- Ambiguity is judged per TREE, not per org_zone string: every authority's -- org_zone claim is resolved to its tree root, a tree claimed by more than --- one distinct organization is vetoed, and ownership is only assigned when at +-- one distinct organisation is vetoed, and ownership is only assigned when at -- least one claim names the root itself (a claim on a sub-zone alone must --- not annex the whole tree). Rows whose (organization_id, name) would collide +-- not annex the whole tree). Rows whose (organisation_id, name) would collide -- with the partial unique indexes are skipped (left NULL for a human) — -- duplicate names within one estate are legacy race artifacts. -- +micrate StatementBegin @@ -93,125 +93,125 @@ WITH RECURSIVE zone_roots AS ( tree_claims AS ( SELECT r.root_id, (a.config->>'org_zone' = r.root_id) AS names_root, - a.organization_id + a.organisation_id FROM "authority" a INNER JOIN zone_roots r ON r.id = a.config->>'org_zone' WHERE COALESCE(a.config->>'org_zone', '') <> '' - AND a.organization_id IS NOT NULL + AND a.organisation_id IS NOT NULL ), org_owner AS ( - SELECT root_id, MIN(organization_id::text)::uuid AS organization_id + SELECT root_id, MIN(organisation_id::text)::uuid AS organisation_id FROM tree_claims GROUP BY root_id - HAVING COUNT(DISTINCT organization_id) = 1 + HAVING COUNT(DISTINCT organisation_id) = 1 AND bool_or(names_root) ), candidates AS ( - SELECT z.id, o.organization_id, z.name, + SELECT z.id, o.organisation_id, z.name, ROW_NUMBER() OVER ( - PARTITION BY o.organization_id, z.name + PARTITION BY o.organisation_id, z.name ORDER BY z.created_at, z.id ) AS rn FROM "zone" z INNER JOIN zone_roots r ON z.id = r.id INNER JOIN org_owner o ON r.root_id = o.root_id - WHERE z.organization_id IS NULL + WHERE z.organisation_id IS NULL ) UPDATE "zone" z -SET organization_id = c.organization_id +SET organisation_id = c.organisation_id FROM candidates c WHERE z.id = c.id AND c.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "zone" x - WHERE x.organization_id = c.organization_id AND x.name = z.name AND x.id <> z.id + WHERE x.organisation_id = c.organisation_id AND x.name = z.name AND x.id <> z.id ); -- +micrate StatementEnd --- 4. Systems: single distinct organization across their zones. Same collision --- skip as zones (sys carries a (organization_id, name) partial unique index). +-- 4. Systems: single distinct organisation across their zones. Same collision +-- skip as zones (sys carries a (organisation_id, name) partial unique index). -- +micrate StatementBegin WITH sys_candidates AS ( SELECT s2.id AS sys_id, s2.name, - MIN(z.organization_id::text)::uuid AS organization_id + MIN(z.organisation_id::text)::uuid AS organisation_id FROM "sys" s2 INNER JOIN "zone" z ON z.id = ANY(s2.zones) - WHERE z.organization_id IS NOT NULL - AND s2.organization_id IS NULL + WHERE z.organisation_id IS NOT NULL + AND s2.organisation_id IS NULL GROUP BY s2.id, s2.name - HAVING COUNT(DISTINCT z.organization_id) = 1 + HAVING COUNT(DISTINCT z.organisation_id) = 1 ), ranked AS ( - SELECT sys_id, name, organization_id, + SELECT sys_id, name, organisation_id, ROW_NUMBER() OVER ( - PARTITION BY organization_id, name + PARTITION BY organisation_id, name ORDER BY sys_id ) AS rn FROM sys_candidates ) UPDATE "sys" s -SET organization_id = r.organization_id +SET organisation_id = r.organisation_id FROM ranked r WHERE s.id = r.sys_id AND r.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "sys" x - WHERE x.organization_id = r.organization_id AND x.name = s.name AND x.id <> s.id + WHERE x.organisation_id = r.organisation_id AND x.name = s.name AND x.id <> s.id ); -- +micrate StatementEnd -- 5a. Logic modules via their control system UPDATE "mod" m -SET organization_id = s.organization_id +SET organisation_id = s.organisation_id FROM "sys" s WHERE m.control_system_id = s.id - AND s.organization_id IS NOT NULL - AND m.organization_id IS NULL; + AND s.organisation_id IS NOT NULL + AND m.organisation_id IS NULL; -- 5b. Device/service modules via the systems that reference them UPDATE "mod" m -SET organization_id = sc.organization_id +SET organisation_id = sc.organisation_id FROM ( - SELECT m2.id AS mod_id, MIN(s.organization_id::text)::uuid AS organization_id + SELECT m2.id AS mod_id, MIN(s.organisation_id::text)::uuid AS organisation_id FROM "mod" m2 INNER JOIN "sys" s ON m2.id = ANY(s.modules) - WHERE s.organization_id IS NOT NULL + WHERE s.organisation_id IS NOT NULL GROUP BY m2.id - HAVING COUNT(DISTINCT s.organization_id) = 1 + HAVING COUNT(DISTINCT s.organisation_id) = 1 ) sc -WHERE m.id = sc.mod_id AND m.organization_id IS NULL; +WHERE m.id = sc.mod_id AND m.organisation_id IS NULL; -- 6. System-scoped trigger definitions UPDATE "trigger" t -SET organization_id = s.organization_id +SET organisation_id = s.organisation_id FROM "sys" s WHERE t.control_system_id = s.id - AND s.organization_id IS NOT NULL - AND t.organization_id IS NULL; + AND s.organisation_id IS NOT NULL + AND t.organisation_id IS NULL; -- 7. Edges via their bound user's authority. Same collision skip (edge --- carries a (organization_id, name) partial unique index). +-- carries a (organisation_id, name) partial unique index). -- +micrate StatementBegin WITH edge_candidates AS ( - SELECT e.id, e.name, a.organization_id, + SELECT e.id, e.name, a.organisation_id, ROW_NUMBER() OVER ( - PARTITION BY a.organization_id, e.name + PARTITION BY a.organisation_id, e.name ORDER BY e.id ) AS rn FROM "edge" e INNER JOIN "user" u ON e.user_id = u.id INNER JOIN "authority" a ON u.authority_id = a.id - WHERE a.organization_id IS NOT NULL - AND e.organization_id IS NULL + WHERE a.organisation_id IS NOT NULL + AND e.organisation_id IS NULL ) UPDATE "edge" e -SET organization_id = c.organization_id +SET organisation_id = c.organisation_id FROM edge_candidates c WHERE e.id = c.id AND c.rn = 1 AND NOT EXISTS ( SELECT 1 FROM "edge" x - WHERE x.organization_id = c.organization_id AND x.name = e.name AND x.id <> e.id + WHERE x.organisation_id = c.organisation_id AND x.name = e.name AND x.id <> e.id ); -- +micrate StatementEnd @@ -219,4 +219,4 @@ WHERE e.id = c.id -- Deliberate no-op: the backfill only fills NULLs and creates rows that -- operators may since have adopted; unwinding it automatically could destroy -- hand-made ownership assignments. Rolling back the schema migration --- (20260818100500000) removes the columns and tables wholesale. +-- (20261007100500000) removes the columns and tables wholesale. diff --git a/migration/db/migrations/20260819100500000_add_grants.sql b/migration/db/migrations/20261007100700000_add_grants.sql similarity index 87% rename from migration/db/migrations/20260819100500000_add_grants.sql rename to migration/db/migrations/20261007100700000_add_grants.sql index f7073c09..65b18537 100644 --- a/migration/db/migrations/20260819100500000_add_grants.sql +++ b/migration/db/migrations/20261007100700000_add_grants.sql @@ -8,12 +8,12 @@ -- PPT-526 Stage 3: the authorization `grants` table (Zanzibar-lite tuple). -- -- A grant gives a user a permission bitmask over a scope, which is one of a --- Partner, a Organization, or an Authority. Authorization walks UP from the touched --- resource (authority -> its organization -> that organization's partner) and ORs every +-- Partner, a Organisation, or an Authority. Authorization walks UP from the touched +-- resource (authority -> its organisation -> that organisation's partner) and ORs every -- live grant found on the chain, so a single grant at partner scope applies to --- all of that partner's organizations, current and future (decision b, 2026-08-19). +-- all of that partner's organisations, current and future (decision b, 2026-08-19). -- --- `scope_id` is polymorphic (a partner/organization UUID as text, or an authority +-- `scope_id` is polymorphic (a partner/organisation UUID as text, or an authority -- TEXT id), so it carries no DB foreign key. A grant naming a deleted scope is -- inert: resolution walks up from live resources and never matches it, so -- orphan grants are harmless and can be swept lazily. The one real FK is @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS "grants"( id UUID PRIMARY KEY DEFAULT uuidv7(), user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, - scope_type TEXT NOT NULL CHECK (scope_type IN ('partner', 'organization', 'authority')), + scope_type TEXT NOT NULL CHECK (scope_type IN ('partner', 'organisation', 'authority')), scope_id TEXT NOT NULL, permissions INTEGER NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ, @@ -43,7 +43,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS grants_user_scope_unique CREATE INDEX IF NOT EXISTS grants_user_id_index ON "grants" USING BTREE (user_id); --- "who can reach this scope" (partner/organization admin console, audit). +-- "who can reach this scope" (partner/organisation admin console, audit). CREATE INDEX IF NOT EXISTS grants_scope_index ON "grants" USING BTREE (scope_type, scope_id); diff --git a/spec/generator.cr b/spec/generator.cr index d388fbe1..ab30bff8 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -573,8 +573,8 @@ module PlaceOS::Model ) end - def self.organization(partner : Partner? = nil, payer : String = Organization::PAYER_PARTNER) - Organization.new( + def self.organisation(partner : Partner? = nil, payer : String = Organisation::PAYER_PARTNER) + Organisation.new( name: Faker::Hacker.noun + "-" + RANDOM.hex(3), partner_id: partner.try(&.id), payer: payer, diff --git a/spec/grant_spec.cr b/spec/grant_spec.cr index ed9bce3d..ce03b9e1 100644 --- a/spec/grant_spec.cr +++ b/spec/grant_spec.cr @@ -1,11 +1,11 @@ require "./helper" # Build partner -> org -> authority -> user, returning all four. -private def build_estate(payer = PlaceOS::Model::Organization::PAYER_PARTNER) +private def build_estate(payer = PlaceOS::Model::Organisation::PAYER_PARTNER) partner = PlaceOS::Model::Generator.partner.save! - org = PlaceOS::Model::Generator.organization(partner: partner, payer: payer).save! + org = PlaceOS::Model::Generator.organisation(partner: partner, payer: payer).save! authority = PlaceOS::Model::Generator.authority(domain: "grant-#{RANDOM.hex(4)}.example.com") - authority.organization_id = org.id + authority.organisation_id = org.id authority.save! user = PlaceOS::Model::Generator.user(authority: authority).save! {partner, org, authority, user} @@ -17,13 +17,13 @@ module PlaceOS::Model Grant.clear Authority.clear User.clear - Organization.clear + Organisation.clear Partner.clear end it "saves a grant with a permission bitmask" do _, org, _, user = build_estate - grant = Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Manage).save! + grant = Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s, Permissions::Manage).save! grant.persisted?.should be_true grant.permission_flags.should eq Permissions::Manage end @@ -43,19 +43,19 @@ module PlaceOS::Model it "resolves a org-scope grant onto the org's authority" do _, org, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Operate).save! + Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s, Permissions::Operate).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Operate end it "resolves a partner-scope grant onto every authority under that partner (decision b)" do partner = Generator.partner.save! - client_a = Generator.organization(partner: partner).save! - client_b = Generator.organization(partner: partner).save! + client_a = Generator.organisation(partner: partner).save! + client_b = Generator.organisation(partner: partner).save! auth_a = Generator.authority(domain: "a-#{RANDOM.hex(4)}.example.com") - auth_a.organization_id = client_a.id + auth_a.organisation_id = client_a.id auth_a.save! auth_b = Generator.authority(domain: "b-#{RANDOM.hex(4)}.example.com") - auth_b.organization_id = client_b.id + auth_b.organisation_id = client_b.id auth_b.save! staff = Generator.user(authority: auth_a).save! @@ -68,7 +68,7 @@ module PlaceOS::Model it "ORs permissions across scopes on the chain" do partner, org, authority, user = build_estate Generator.grant(user, Grant::SCOPE_PARTNER, partner.id.not_nil!.to_s, Permissions::Read).save! - Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Update).save! + Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s, Permissions::Update).save! Grant.resolve(user.id.not_nil!, authority).should eq(Permissions::Read | Permissions::Update) end @@ -82,14 +82,14 @@ module PlaceOS::Model it "ignores expired grants" do _, org, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Manage, + Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s, Permissions::Manage, expires_at: Time.utc - 1.hour).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::None end it "honours a future expiry" do _, org, authority, user = build_estate - Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s, Permissions::Read, + Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s, Permissions::Read, expires_at: Time.utc + 1.hour).save! Grant.resolve(user.id.not_nil!, authority).should eq Permissions::Read end @@ -101,7 +101,7 @@ module PlaceOS::Model it "cascades on user delete" do _, org, _, user = build_estate - grant = Generator.grant(user, Grant::SCOPE_ORGANIZATION, org.id.not_nil!.to_s).save! + grant = Generator.grant(user, Grant::SCOPE_ORGANISATION, org.id.not_nil!.to_s).save! user.destroy Grant.find?(grant.id.not_nil!).should be_nil end diff --git a/spec/helper.cr b/spec/helper.cr index 5f5f7022..d71d5b73 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -40,7 +40,7 @@ Spec.after_suite do PlaceOS::Model::GroupPlaylist, PlaceOS::Model::Group, PlaceOS::Model::Grant, - PlaceOS::Model::Organization, + PlaceOS::Model::Organisation, PlaceOS::Model::Partner, ].each(&.clear) end diff --git a/spec/organization_spec.cr b/spec/organisation_spec.cr similarity index 62% rename from spec/organization_spec.cr rename to spec/organisation_spec.cr index 8d166f49..8669b67b 100644 --- a/spec/organization_spec.cr +++ b/spec/organisation_spec.cr @@ -1,33 +1,33 @@ require "./helper" module PlaceOS::Model - describe Organization do + describe Organisation do Spec.before_each do ControlSystem.clear Zone.clear Authority.clear - Organization.clear + Organisation.clear Partner.clear end it "saves a partnered org with partner-pays default" do partner = Generator.partner.save! - org = Generator.organization(partner: partner).save! + org = Generator.organisation(partner: partner).save! org.persisted?.should be_true - org.payer.should eq Organization::PAYER_PARTNER + org.payer.should eq Organisation::PAYER_PARTNER org.self_managed?.should be_false org.partner.try(&.id).should eq partner.id end it "normalizes a org-owned org to pay for itself" do - org = Generator.organization.save! + org = Generator.organisation.save! org.partner_id.should be_nil - org.payer.should eq Organization::PAYER_ORGANIZATION + org.payer.should eq Organisation::PAYER_ORGANISATION org.self_managed?.should be_true end it "rejects an unknown payer" do - org = Generator.organization(partner: Generator.partner.save!, payer: "nobody") + org = Generator.organisation(partner: Generator.partner.save!, payer: "nobody") org.valid?.should be_false org.errors.map(&.field).should contain(:payer) end @@ -35,52 +35,52 @@ module PlaceOS::Model it "scopes org name uniqueness to the partner" do partner_a = Generator.partner.save! partner_b = Generator.partner.save! - original = Generator.organization(partner: partner_a).save! + original = Generator.organisation(partner: partner_a).save! # Same name under a different partner is fine - sibling = Generator.organization(partner: partner_b) + sibling = Generator.organisation(partner: partner_b) sibling.name = original.name sibling.valid?.should be_true # Same name under the same partner is not - duplicate = Generator.organization(partner: partner_a) + duplicate = Generator.organisation(partner: partner_a) duplicate.name = original.name duplicate.valid?.should be_false duplicate.errors.map(&.field).should contain(:name) end it "rejects duplicate names among org-owned clients" do - original = Generator.organization.save! - duplicate = Generator.organization + original = Generator.organisation.save! + duplicate = Generator.organisation duplicate.name = original.name duplicate.valid?.should be_false duplicate.errors.map(&.field).should contain(:name) end - it "owns authorities via authority.organization_id" do - org = Generator.organization.save! + it "owns authorities via authority.organisation_id" do + org = Generator.organisation.save! authority = Generator.authority(domain: "org-spec.example.com") - authority.organization_id = org.id + authority.organisation_id = org.id authority.save! - authority.organization.try(&.id).should eq org.id + authority.organisation.try(&.id).should eq org.id org.authorities.to_a.map(&.id).should eq [authority.id] end it "refuses to delete a org that still owns a domain" do - org = Generator.organization.save! + org = Generator.organisation.save! authority = Generator.authority(domain: "org-restrict.example.com") - authority.organization_id = org.id + authority.organisation_id = org.id authority.save! expect_raises(Exception, /foreign key/) { org.destroy } - Organization.find?(org.id.not_nil!).should_not be_nil + Organisation.find?(org.id.not_nil!).should_not be_nil end it "rejects renaming a org onto a sibling's name" do partner = Generator.partner.save! - original = Generator.organization(partner: partner).save! - sibling = Generator.organization(partner: partner).save! + original = Generator.organisation(partner: partner).save! + sibling = Generator.organisation(partner: partner).save! sibling.name = original.name sibling.valid?.should be_false @@ -88,17 +88,17 @@ module PlaceOS::Model end it "records org ownership on zones and systems" do - org = Generator.organization.save! + org = Generator.organisation.save! zone = Generator.zone - zone.organization_id = org.id + zone.organisation_id = org.id zone.save! sys = Generator.control_system - sys.organization_id = org.id + sys.organisation_id = org.id sys.save! - Zone.find!(zone.id.not_nil!).organization.try(&.id).should eq org.id - ControlSystem.find!(sys.id.not_nil!).organization.try(&.id).should eq org.id + Zone.find!(zone.id.not_nil!).organisation.try(&.id).should eq org.id + ControlSystem.find!(sys.id.not_nil!).organisation.try(&.id).should eq org.id end end end diff --git a/spec/partner_spec.cr b/spec/partner_spec.cr index 492c98ab..74a270d5 100644 --- a/spec/partner_spec.cr +++ b/spec/partner_spec.cr @@ -6,7 +6,7 @@ module PlaceOS::Model ControlSystem.clear Zone.clear Authority.clear - Organization.clear + Organisation.clear Partner.clear end @@ -54,14 +54,14 @@ module PlaceOS::Model it "lists its clients" do partner = Generator.partner.save! - org = Generator.organization(partner: partner).save! - Generator.organization.save! - partner.organizations.to_a.map(&.id).should eq [org.id] + org = Generator.organisation(partner: partner).save! + Generator.organisation.save! + partner.organisations.to_a.map(&.id).should eq [org.id] end it "refuses to delete a partner that still has clients" do partner = Generator.partner.save! - Generator.organization(partner: partner).save! + Generator.organisation(partner: partner).save! expect_raises(Exception, /foreign key/) { partner.destroy } Partner.find?(partner.id.not_nil!).should_not be_nil diff --git a/src/placeos-models/authority.cr b/src/placeos-models/authority.cr index 6a90314b..3717d9b8 100644 --- a/src/placeos-models/authority.cr +++ b/src/placeos-models/authority.cr @@ -28,9 +28,9 @@ module PlaceOS::Model attribute email_domains : Array(String) = [] of String - # PPT-526: the Organization (customer organisation) that owns this domain. + # PPT-526: the Organisation (customer organisation) that owns this domain. # Nullable while ownership backfill and provisioning are phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false macro finished # Ensure only the host is saved. @@ -77,9 +77,9 @@ module PlaceOS::Model Authority.where(domain: host).first? end - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end # Locates an authority by email domain diff --git a/src/placeos-models/broker.cr b/src/placeos-models/broker.cr index f643fd8e..73e47ea2 100644 --- a/src/placeos-models/broker.cr +++ b/src/placeos-models/broker.cr @@ -33,14 +33,14 @@ module PlaceOS::Model # Matches will be replaced with a hmac_256(secret, match). attribute filters : Array(String) = -> { [] of String } - # PPT-526: the Organization (customer organisation) that owns this broker. + # PPT-526: the Organisation (customer organisation) that owns this broker. # NULL = cluster-level infrastructure (the default; the backfill never # assigns brokers). Nullable while query enforcement is phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end # Validation diff --git a/src/placeos-models/control_system.cr b/src/placeos-models/control_system.cr index f99f5a6a..2aea96be 100644 --- a/src/placeos-models/control_system.cr +++ b/src/placeos-models/control_system.cr @@ -42,13 +42,13 @@ module PlaceOS::Model # Array of security group ids for room access attribute security_groups : Array(String) = -> { [] of String } - # PPT-526: the Organization (customer organisation) that owns this system. + # PPT-526: the Organisation (customer organisation) that owns this system. # Nullable while ownership backfill and query enforcement are phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end attribute timezone : Time::Location?, converter: Time::Location::Converter, es_type: "text" diff --git a/src/placeos-models/edge.cr b/src/placeos-models/edge.cr index d703d1bb..aae3b98f 100644 --- a/src/placeos-models/edge.cr +++ b/src/placeos-models/edge.cr @@ -20,13 +20,13 @@ module PlaceOS::Model attribute last_seen : Time?, converter: Time::EpochConverterOptional, mass_assignment: false attribute online : Bool = false, mass_assignment: false - # PPT-526: the Organization (customer organisation) that owns this edge node. + # PPT-526: the Organisation (customer organisation) that owns this edge node. # Nullable while ownership backfill and query enforcement are phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end @[JSON::Field(ignore: true)] diff --git a/src/placeos-models/grant.cr b/src/placeos-models/grant.cr index 459830c4..5907a83b 100644 --- a/src/placeos-models/grant.cr +++ b/src/placeos-models/grant.cr @@ -4,20 +4,20 @@ require "uuid/json" require "./base/model" require "./permissions" require "./authority" -require "./organization" +require "./organisation" require "./partner" module PlaceOS::Model # PPT-526 Stage 3: a cross-tenant authorization grant. # - # Gives a user a `Permissions` bitmask over one scope — a Partner, an Organization, + # Gives a user a `Permissions` bitmask over one scope — a Partner, an Organisation, # or an Authority. Authorization resolves by walking UP from the touched - # resource: authority -> its organization -> that organization's partner. A grant at - # partner scope therefore covers every one of that partner's organizations, current - # and future (the NTT-over-its-clients case); a grant at organization scope covers - # that organization's authorities; a grant at authority scope is a single domain. + # resource: authority -> its organisation -> that organisation's partner. A grant at + # partner scope therefore covers every one of that partner's organisations, current + # and future (the NTT-over-its-clients case); a grant at organisation scope covers + # that organisation's authorities; a grant at authority scope is a single domain. # - # `scope_id` is polymorphic (a Partner/Organization UUID as text, or an Authority + # `scope_id` is polymorphic (a Partner/Organisation UUID as text, or an Authority # TEXT id) so it carries no FK; a grant naming a deleted scope is inert. class Grant < ::PgORM::Base include PgORM::Timestamps @@ -25,9 +25,9 @@ module PlaceOS::Model table :grants SCOPE_PARTNER = "partner" - SCOPE_ORGANIZATION = "organization" + SCOPE_ORGANISATION = "organisation" SCOPE_AUTHORITY = "authority" - SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_ORGANIZATION, SCOPE_AUTHORITY] + SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_ORGANISATION, SCOPE_AUTHORITY] default_primary_key id : UUID, autogenerated: true @@ -78,7 +78,7 @@ module PlaceOS::Model # ------------------------------------------------------------------ # Effective permissions a user holds over `authority`, resolved by walking - # up authority -> organization -> partner and OR-ing every live grant on the + # up authority -> organisation -> partner and OR-ing every live grant on the # chain. Explicit grants only; the management-partner "support sees all" # rule is applied by the enforcement layer, not here. def self.resolve(user_id : String, authority : Authority, at : Time = Time.utc) : Permissions @@ -93,13 +93,13 @@ module PlaceOS::Model end # The (scope_type, scope_id) pairs to check for a given authority, from - # most specific to least: the authority itself, its organization, that organization's + # most specific to least: the authority itself, its organisation, that organisation's # partner. Missing links are simply absent. def self.scope_chain(authority : Authority) : Array({String, String}) chain = [{SCOPE_AUTHORITY, authority.id.to_s}] - if (organization_id = authority.organization_id) - chain << {SCOPE_ORGANIZATION, organization_id.to_s} - if (organization = Organization.find?(organization_id)) && (partner_id = organization.partner_id) + if (organisation_id = authority.organisation_id) + chain << {SCOPE_ORGANISATION, organisation_id.to_s} + if (organisation = Organisation.find?(organisation_id)) && (partner_id = organisation.partner_id) chain << {SCOPE_PARTNER, partner_id.to_s} end end @@ -127,7 +127,7 @@ module PlaceOS::Model Grant.where(user_id: user_id).select(&.live?) end - # Everyone granted access to a specific scope (partner/organization admin console). + # Everyone granted access to a specific scope (partner/organisation admin console). def self.for_scope(scope_type : String, scope_id : String) : Array(Grant) Grant.where(scope_type: scope_type, scope_id: scope_id).to_a end diff --git a/src/placeos-models/module.cr b/src/placeos-models/module.cr index 939176ea..11cf9898 100644 --- a/src/placeos-models/module.cr +++ b/src/placeos-models/module.cr @@ -48,15 +48,15 @@ module PlaceOS::Model attribute ignore_connected : Bool = false attribute ignore_startstop : Bool = false - # PPT-526: the Organization (customer organisation) that owns this module. + # PPT-526: the Organisation (customer organisation) that owns this module. # Nullable while ownership backfill and query enforcement are phased in; - # shared device/service modules referenced by several organizations' systems + # shared device/service modules referenced by several organisations' systems # stay NULL until a sharing policy is decided. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end # Runtime Error Indicators diff --git a/src/placeos-models/organization.cr b/src/placeos-models/organisation.cr similarity index 68% rename from src/placeos-models/organization.cr rename to src/placeos-models/organisation.cr index 5294211b..92d5c330 100644 --- a/src/placeos-models/organization.cr +++ b/src/placeos-models/organisation.cr @@ -6,23 +6,23 @@ require "./partner" module PlaceOS::Model # A customer organisation (e.g. UCLA, Acadian) — the estate owner in the - # PPT-526 hierarchy: Partner -> Organization -> Authority (domain) -> ... - # This is the entity the multi-tenancy-template calls "Organization". + # PPT-526 hierarchy: Partner -> Organisation -> Authority (domain) -> ... + # This is the entity the multi-tenancy-template calls "Organisation". # # `partner_id` NULL means client-owned: the customer signed up and manages # itself without an integrator. `payer` records who receives invoices for - # this organization: the partner (integrator pays us and re-bills — the - # default channel model) or the organization directly. An organization + # this organisation: the partner (integrator pays us and re-bills — the + # default channel model) or the organisation directly. An organisation # without a partner can only pay for itself; `normalize_payer` keeps that # invariant ahead of the DB CHECK constraint. - class Organization < ::PgORM::Base + class Organisation < ::PgORM::Base include PgORM::Timestamps - table :organizations + table :organisations PAYER_PARTNER = "partner" - PAYER_ORGANIZATION = "organization" - PAYERS = [PAYER_PARTNER, PAYER_ORGANIZATION] + PAYER_ORGANISATION = "organisation" + PAYERS = [PAYER_PARTNER, PAYER_ORGANISATION] default_primary_key id : UUID, autogenerated: true @@ -38,25 +38,25 @@ module PlaceOS::Model validates :name, presence: true - validate ->(this : Organization) { + validate ->(this : Organisation) { unless PAYERS.includes?(this.payer) this.validation_error(:payer, "must be one of #{PAYERS.join(", ")}") end } - validate ->(this : Organization) { + validate ->(this : Organisation) { # Friendlier error ahead of the DB's partial unique indexes: names are # unique within a partner's book, and unique among client-owned orgs. name = this.name return if name.nil? || name.empty? - existing = Organization.where(partner_id: this.partner_id, name: name).first? + existing = Organisation.where(partner_id: this.partner_id, name: name).first? return if existing.nil? return if this.persisted? && existing.id == this.id - this.validation_error(:name, "an organization with this name already exists") + this.validation_error(:name, "an organisation with this name already exists") } protected def normalize_payer - self.payer = PAYER_ORGANIZATION if self.partner_id.nil? + self.payer = PAYER_ORGANISATION if self.partner_id.nil? end # True when the customer manages itself without an integrator (no partner). @@ -64,9 +64,9 @@ module PlaceOS::Model self.partner_id.nil? end - # Domains (authorities) owned by this organization. + # Domains (authorities) owned by this organisation. def authorities - Authority.where(organization_id: self.id) + Authority.where(organisation_id: self.id) end end end diff --git a/src/placeos-models/partner.cr b/src/placeos-models/partner.cr index 82325f08..eb88516b 100644 --- a/src/placeos-models/partner.cr +++ b/src/placeos-models/partner.cr @@ -64,9 +64,9 @@ module PlaceOS::Model Partner.where(parent_id: self.id) end - # Organizations under this partner's book of business. - def organizations - Organization.where(partner_id: self.id) + # Organisations under this partner's book of business. + def organisations + Organisation.where(partner_id: self.id) end end end diff --git a/src/placeos-models/trigger.cr b/src/placeos-models/trigger.cr index 21e3bc10..201de4cc 100644 --- a/src/placeos-models/trigger.cr +++ b/src/placeos-models/trigger.cr @@ -27,14 +27,14 @@ module PlaceOS::Model METHODS = %w(GET POST PUT PATCH DELETE) attribute supported_methods : Array(String) = ["POST"] - # PPT-526: the Organization (customer organisation) that owns this trigger + # PPT-526: the Organisation (customer organisation) that owns this trigger # definition. NULL = shared/cluster-wide definition. Nullable while # ownership backfill and query enforcement are phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end # Association diff --git a/src/placeos-models/zone.cr b/src/placeos-models/zone.cr index a7ad383f..eb0a908c 100644 --- a/src/placeos-models/zone.cr +++ b/src/placeos-models/zone.cr @@ -49,13 +49,13 @@ module PlaceOS::Model attribute images : Array(String) = [] of String attribute playlists : Array(String) = [] of String, es_type: "keyword" - # PPT-526: the Organization (customer organisation) that owns this zone. + # PPT-526: the Organisation (customer organisation) that owns this zone. # Nullable while ownership backfill and query enforcement are phased in. - attribute organization_id : UUID?, es_type: "keyword", mass_assignment: false + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false - # The owning Organization, when ownership has been assigned. - def organization : PlaceOS::Model::Organization? - self.organization_id.try { |id| PlaceOS::Model::Organization.find?(id) } + # The owning Organisation, when ownership has been assigned. + def organisation : PlaceOS::Model::Organisation? + self.organisation_id.try { |id| PlaceOS::Model::Organisation.find?(id) } end attribute place_id : String? From c7fac7913312531a3df1ed5ada1b7031e3661135 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 14:37:40 +1100 Subject: [PATCH 5/6] feat: PPT-526 partner staff organisations, organisation-scoped names and last-admin guard organisations.partner_staff marks a partner's own staff organisation, whose admins and support users reach every organisation under that partner. Zone, system, edge and broker names are unique within an organisation rather than across the cluster, and the last-admin guard counts admins within the organisation of the user being removed. --- ..._alter_organisations_add_partner_staff.sql | 22 +++++++++++++++++++ spec/generator.cr | 3 ++- spec/organisation_spec.cr | 10 +++++++++ spec/user_spec.cr | 19 ++++++++++++++++ spec/zone_spec.cr | 20 +++++++++++++++++ src/placeos-models/broker.cr | 4 +++- src/placeos-models/control_system.cr | 7 +++--- src/placeos-models/edge.cr | 6 ++--- src/placeos-models/organisation.cr | 20 +++++++++++++++++ .../organisation_scoped_name.cr | 19 ++++++++++++++++ src/placeos-models/user.cr | 10 +++++++-- src/placeos-models/zone.cr | 6 ++--- 12 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 migration/db/migrations/20261007100800000_alter_organisations_add_partner_staff.sql create mode 100644 src/placeos-models/organisation_scoped_name.cr diff --git a/migration/db/migrations/20261007100800000_alter_organisations_add_partner_staff.sql b/migration/db/migrations/20261007100800000_alter_organisations_add_partner_staff.sql new file mode 100644 index 00000000..6ebabf04 --- /dev/null +++ b/migration/db/migrations/20261007100800000_alter_organisations_add_partner_staff.sql @@ -0,0 +1,22 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied +-- --------------------------------------------------------------------------- +-- PPT-526: an organisation flagged partner_staff is the partner's own staff +-- organisation. Its admins and support users administer every organisation +-- under that partner (partner reach), the way the management partner's staff +-- organisation administers the whole cluster. +-- --------------------------------------------------------------------------- +ALTER TABLE "organisations" ADD COLUMN IF NOT EXISTS partner_staff BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE "organisations" DROP CONSTRAINT IF EXISTS organisations_partner_staff_needs_partner; +ALTER TABLE "organisations" ADD CONSTRAINT organisations_partner_staff_needs_partner + CHECK (NOT partner_staff OR partner_id IS NOT NULL); + +CREATE INDEX IF NOT EXISTS organisations_partner_staff_index + ON "organisations" USING BTREE (partner_id) WHERE partner_staff; + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back +DROP INDEX IF EXISTS organisations_partner_staff_index; +ALTER TABLE "organisations" DROP CONSTRAINT IF EXISTS organisations_partner_staff_needs_partner; +ALTER TABLE "organisations" DROP COLUMN IF EXISTS partner_staff; diff --git a/spec/generator.cr b/spec/generator.cr index ab30bff8..2cb47e8f 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -573,11 +573,12 @@ module PlaceOS::Model ) end - def self.organisation(partner : Partner? = nil, payer : String = Organisation::PAYER_PARTNER) + def self.organisation(partner : Partner? = nil, payer : String = Organisation::PAYER_PARTNER, partner_staff : Bool = false) Organisation.new( name: Faker::Hacker.noun + "-" + RANDOM.hex(3), partner_id: partner.try(&.id), payer: payer, + partner_staff: partner_staff, ) end diff --git a/spec/organisation_spec.cr b/spec/organisation_spec.cr index 8669b67b..f85d5d48 100644 --- a/spec/organisation_spec.cr +++ b/spec/organisation_spec.cr @@ -26,6 +26,16 @@ module PlaceOS::Model org.self_managed?.should be_true end + it "requires a partner for a partner staff organisation" do + org = Generator.organisation(partner_staff: true) + org.valid?.should be_false + org.errors.map(&.field).should contain(:partner_staff) + + staff = Generator.organisation(partner: Generator.partner.save!, partner_staff: true).save! + staff.partner_staff.should be_true + Organisation.staff_of(staff.partner_id.as(UUID)).to_a.map(&.id).should eq [staff.id] + end + it "rejects an unknown payer" do org = Generator.organisation(partner: Generator.partner.save!, payer: "nobody") org.valid?.should be_false diff --git a/spec/user_spec.cr b/spec/user_spec.cr index 3e19fd9b..b3ba91cf 100644 --- a/spec/user_spec.cr +++ b/spec/user_spec.cr @@ -161,6 +161,25 @@ module PlaceOS::Model end end + it "counts remaining admins within the organisation, not the cluster" do + User.clear + org_a = Generator.organisation.save! + org_b = Generator.organisation.save! + authority_a = Generator.authority("a.test").tap(&.organisation_id = org_a.id).save! + authority_b = Generator.authority("b.test").tap(&.organisation_id = org_b.id).save! + admin_a = Generator.user(authority_a, admin: true).save! + Generator.user(authority_b, admin: true).save! + + # another organisation's admin does not count for this one + expect_raises(Model::Error, "At least one admin must remain") do + admin_a.destroy + end + + # a second admin in the same organisation does + Generator.user(authority_a, admin: true).save! + admin_a.destroy + end + it "does not raise if more than one sys_admin User remains" do User.clear user0 = Generator.user(admin: true).save! diff --git a/spec/zone_spec.cr b/spec/zone_spec.cr index 10936b4c..3fd6e1d5 100644 --- a/spec/zone_spec.cr +++ b/spec/zone_spec.cr @@ -34,6 +34,26 @@ module PlaceOS::Model end end + it "scopes name uniqueness to the organisation" do + org_a = Generator.organisation.save! + org_b = Generator.organisation.save! + name = "Boardroom #{RANDOM.hex(3)}" + + first = Generator.zone.tap { |z| z.name = name; z.organisation_id = org_a.id }.save! + Generator.zone.tap { |z| z.name = name; z.organisation_id = org_b.id }.save! + + duplicate = Generator.zone.tap { |z| z.name = name; z.organisation_id = org_a.id } + duplicate.valid?.should be_false + duplicate.errors.map(&.field).should contain(:name) + + # rows with no organisation are unique among themselves + unowned = Generator.zone.tap { |z| z.name = name }.save! + Generator.zone.tap { |z| z.name = name }.valid?.should be_false + + first.destroy + unowned.destroy + end + it "has unique tags" do zone = Generator.zone zone.tags << "hello" diff --git a/src/placeos-models/broker.cr b/src/placeos-models/broker.cr index 73e47ea2..07691616 100644 --- a/src/placeos-models/broker.cr +++ b/src/placeos-models/broker.cr @@ -2,6 +2,7 @@ require "openssl" require "random" require "./base/model" +require "./organisation_scoped_name" module PlaceOS::Model class Broker < ModelBase @@ -50,7 +51,8 @@ module PlaceOS::Model validates :host, presence: true validates :secret, presence: true - ensure_unique :name + include OrganisationScopedName + ensure_unique_name_within_organisation validate ->Broker.validate_filters(Broker) diff --git a/src/placeos-models/control_system.cr b/src/placeos-models/control_system.cr index 2aea96be..7744bbca 100644 --- a/src/placeos-models/control_system.cr +++ b/src/placeos-models/control_system.cr @@ -5,6 +5,7 @@ require "future" require "./converter/time_location" require "./base/model" +require "./organisation_scoped_name" require "./settings" require "./email" require "./utilities/settings_helper" @@ -141,10 +142,8 @@ module PlaceOS::Model # Zones and settings are only required for confident coding validates :name, presence: true - # TODO: Ensure unique regardless of casing - ensure_unique :name do |name| - name.strip - end + include OrganisationScopedName + ensure_unique_name_within_organisation # Validate URIs validate ->(this : ControlSystem) { diff --git a/src/placeos-models/edge.cr b/src/placeos-models/edge.cr index aae3b98f..38f5f4ce 100644 --- a/src/placeos-models/edge.cr +++ b/src/placeos-models/edge.cr @@ -1,4 +1,5 @@ require "./base/model" +require "./organisation_scoped_name" require "./user" require "./api_key" @@ -139,8 +140,7 @@ module PlaceOS::Model # Validation ############################################################################################### - ensure_unique :name do |name| - name.strip - end + include OrganisationScopedName + ensure_unique_name_within_organisation end end diff --git a/src/placeos-models/organisation.cr b/src/placeos-models/organisation.cr index 92d5c330..c47046c0 100644 --- a/src/placeos-models/organisation.cr +++ b/src/placeos-models/organisation.cr @@ -31,6 +31,10 @@ module PlaceOS::Model attribute payer : String = PAYER_PARTNER, mass_assignment: false attribute config : Hash(String, JSON::Any) = {} of String => JSON::Any + # The partner's own staff organisation: its admins and support users reach + # every organisation under `partner_id`. Requires a partner. + attribute partner_staff : Bool = false, mass_assignment: false + attribute partner_id : UUID? belongs_to :partner, class_name: Partner, foreign_key: partner_id @@ -44,6 +48,12 @@ module PlaceOS::Model end } + validate ->(this : Organisation) { + if this.partner_staff && this.partner_id.nil? + this.validation_error(:partner_staff, "requires a partner") + end + } + validate ->(this : Organisation) { # Friendlier error ahead of the DB's partial unique indexes: names are # unique within a partner's book, and unique among client-owned orgs. @@ -68,5 +78,15 @@ module PlaceOS::Model def authorities Authority.where(organisation_id: self.id) end + + # Every organisation under the same partner, this one included. + def partner_organisations + Organisation.where(partner_id: self.partner_id) + end + + # The staff organisations of a partner. + def self.staff_of(partner_id : UUID) + Organisation.where(partner_id: partner_id, partner_staff: true) + end end end diff --git a/src/placeos-models/organisation_scoped_name.cr b/src/placeos-models/organisation_scoped_name.cr new file mode 100644 index 00000000..8c912d13 --- /dev/null +++ b/src/placeos-models/organisation_scoped_name.cr @@ -0,0 +1,19 @@ +require "./base/model" + +module PlaceOS::Model + # Name uniqueness for the organisation-owned estate models (zone, system, + # edge, broker): unique within an organisation, and unique among rows that + # have no organisation. Backed by the partial unique indexes on + # (organisation_id, name). + module OrganisationScopedName + macro ensure_unique_name_within_organisation + validate :name, "should be unique within the organisation", ->(this : self) do + name = this.name.strip + return true if name.empty? + this.name = name unless this.persisted? + existing = self.where(name: name, organisation_id: this.organisation_id).first? + !(existing && (!this.persisted? || existing.id != this.id)) + end + end + end +end diff --git a/src/placeos-models/user.cr b/src/placeos-models/user.cr index cdda60d4..1f8c3292 100644 --- a/src/placeos-models/user.cr +++ b/src/placeos-models/user.cr @@ -146,11 +146,17 @@ module PlaceOS::Model admin_destroy_lock.synchronize { super } end - # Prevent the system from entering a state with no admin + # Prevent an organisation (or, for domains without one, the cluster) from + # entering a state with no admin protected def ensure_admin_remains return unless self.sys_admin - if User.where({sys_admin: true}).count == 1 + admins = User.where({sys_admin: true}) + if organisation_id = Authority.find?(self.authority_id.as(String)).try(&.organisation_id) + admins = admins.where("authority_id IN (SELECT id FROM authority WHERE organisation_id = ?)", organisation_id) + end + + if admins.count == 1 raise Model::Error.new("At least one admin must remain") end end diff --git a/src/placeos-models/zone.cr b/src/placeos-models/zone.cr index eb0a908c..22d7f07d 100644 --- a/src/placeos-models/zone.cr +++ b/src/placeos-models/zone.cr @@ -1,6 +1,7 @@ require "time" require "./base/model" +require "./organisation_scoped_name" require "./settings" require "./utilities/settings_helper" require "./utilities/metadata_helper" @@ -126,9 +127,8 @@ module PlaceOS::Model ############################################################################################### validates :name, presence: true - ensure_unique :name do |name| - name.strip - end + include OrganisationScopedName + ensure_unique_name_within_organisation # Callbacks ############################################################################################### From e21d7904b1d9fe12b7852e9e835a50489647e5f6 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 15:44:36 +1100 Subject: [PATCH 6/6] fix: PPT-526 keep the last-admin guard cluster-wide A per-organisation guard blocks deleting a customer's whole domain, since the organisation's last admin goes with it. The organisation-level rule belongs in the API, where the caller's reach is known. --- spec/user_spec.cr | 19 ------------------- src/placeos-models/user.cr | 10 ++-------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/spec/user_spec.cr b/spec/user_spec.cr index b3ba91cf..3e19fd9b 100644 --- a/spec/user_spec.cr +++ b/spec/user_spec.cr @@ -161,25 +161,6 @@ module PlaceOS::Model end end - it "counts remaining admins within the organisation, not the cluster" do - User.clear - org_a = Generator.organisation.save! - org_b = Generator.organisation.save! - authority_a = Generator.authority("a.test").tap(&.organisation_id = org_a.id).save! - authority_b = Generator.authority("b.test").tap(&.organisation_id = org_b.id).save! - admin_a = Generator.user(authority_a, admin: true).save! - Generator.user(authority_b, admin: true).save! - - # another organisation's admin does not count for this one - expect_raises(Model::Error, "At least one admin must remain") do - admin_a.destroy - end - - # a second admin in the same organisation does - Generator.user(authority_a, admin: true).save! - admin_a.destroy - end - it "does not raise if more than one sys_admin User remains" do User.clear user0 = Generator.user(admin: true).save! diff --git a/src/placeos-models/user.cr b/src/placeos-models/user.cr index 1f8c3292..cdda60d4 100644 --- a/src/placeos-models/user.cr +++ b/src/placeos-models/user.cr @@ -146,17 +146,11 @@ module PlaceOS::Model admin_destroy_lock.synchronize { super } end - # Prevent an organisation (or, for domains without one, the cluster) from - # entering a state with no admin + # Prevent the system from entering a state with no admin protected def ensure_admin_remains return unless self.sys_admin - admins = User.where({sys_admin: true}) - if organisation_id = Authority.find?(self.authority_id.as(String)).try(&.organisation_id) - admins = admins.where("authority_id IN (SELECT id FROM authority WHERE organisation_id = ?)", organisation_id) - end - - if admins.count == 1 + if User.where({sys_admin: true}).count == 1 raise Model::Error.new("At least one admin must remain") end end