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/20261007100600000_backfill_organisation_ownership.sql b/migration/db/migrations/20261007100600000_backfill_organisation_ownership.sql new file mode 100644 index 00000000..904715a2 --- /dev/null +++ b/migration/db/migrations/20261007100600000_backfill_organisation_ownership.sql @@ -0,0 +1,222 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +-- --------------------------------------------------------------------------- +-- PPT-526: backfill Partner/Organisation ownership for existing data. +-- +-- Idempotent and additive: every statement only creates missing rows or fills +-- 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). +-- +-- What it does: +-- 1. Ensures a management partner exists ("PlaceOS" — the platform +-- operator's own organisation). +-- 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 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 +-- 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; +-- 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 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 organisation_id IS NULL LOOP + new_org := NULL; + 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 organisation backfill', + NULL, 'organisation', now(), now() + ) + ON CONFLICT DO NOTHING + 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_org IS NULL THEN + 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 organisation_id = new_org 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 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 (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 +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.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.organisation_id IS NOT NULL +), +org_owner AS ( + SELECT root_id, MIN(organisation_id::text)::uuid AS organisation_id + FROM tree_claims + GROUP BY root_id + HAVING COUNT(DISTINCT organisation_id) = 1 + AND bool_or(names_root) +), +candidates AS ( + SELECT z.id, o.organisation_id, z.name, + ROW_NUMBER() OVER ( + 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.organisation_id IS NULL +) +UPDATE "zone" z +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.organisation_id = c.organisation_id AND x.name = z.name AND x.id <> z.id + ); +-- +micrate StatementEnd + +-- 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.organisation_id::text)::uuid AS organisation_id + FROM "sys" s2 + INNER JOIN "zone" z ON z.id = ANY(s2.zones) + WHERE z.organisation_id IS NOT NULL + AND s2.organisation_id IS NULL + GROUP BY s2.id, s2.name + HAVING COUNT(DISTINCT z.organisation_id) = 1 +), +ranked AS ( + SELECT sys_id, name, organisation_id, + ROW_NUMBER() OVER ( + PARTITION BY organisation_id, name + ORDER BY sys_id + ) AS rn + FROM sys_candidates +) +UPDATE "sys" s +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.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 organisation_id = s.organisation_id +FROM "sys" s +WHERE m.control_system_id = s.id + 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 organisation_id = sc.organisation_id +FROM ( + 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.organisation_id IS NOT NULL + GROUP BY m2.id + HAVING COUNT(DISTINCT s.organisation_id) = 1 +) sc +WHERE m.id = sc.mod_id AND m.organisation_id IS NULL; + +-- 6. System-scoped trigger definitions +UPDATE "trigger" t +SET organisation_id = s.organisation_id +FROM "sys" s +WHERE t.control_system_id = s.id + 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 (organisation_id, name) partial unique index). +-- +micrate StatementBegin +WITH edge_candidates AS ( + SELECT e.id, e.name, a.organisation_id, + ROW_NUMBER() OVER ( + 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.organisation_id IS NOT NULL + AND e.organisation_id IS NULL +) +UPDATE "edge" e +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.organisation_id = c.organisation_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 +-- (20261007100500000) removes the columns and tables wholesale. diff --git a/migration/db/migrations/20261007100700000_add_grants.sql b/migration/db/migrations/20261007100700000_add_grants.sql new file mode 100644 index 00000000..65b18537 --- /dev/null +++ b/migration/db/migrations/20261007100700000_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 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 organisations, current and future (decision b, 2026-08-19). +-- +-- `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 +-- 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', 'organisation', '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/organisation 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/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 cfea5adc..2cb47e8f 100644 --- a/spec/generator.cr +++ b/spec/generator.cr @@ -565,6 +565,40 @@ 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.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 + + 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..ce03b9e1 --- /dev/null +++ b/spec/grant_spec.cr @@ -0,0 +1,109 @@ +require "./helper" + +# Build partner -> org -> authority -> user, returning all four. +private def build_estate(payer = PlaceOS::Model::Organisation::PAYER_PARTNER) + partner = PlaceOS::Model::Generator.partner.save! + org = PlaceOS::Model::Generator.organisation(partner: partner, payer: payer).save! + authority = PlaceOS::Model::Generator.authority(domain: "grant-#{RANDOM.hex(4)}.example.com") + authority.organisation_id = org.id + authority.save! + user = PlaceOS::Model::Generator.user(authority: authority).save! + {partner, org, authority, user} +end + +module PlaceOS::Model + describe Grant do + Spec.before_each do + Grant.clear + Authority.clear + User.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_ORGANISATION, org.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 org-scope grant onto the org's authority" do + _, org, authority, user = build_estate + 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.organisation(partner: partner).save! + client_b = Generator.organisation(partner: partner).save! + auth_a = Generator.authority(domain: "a-#{RANDOM.hex(4)}.example.com") + auth_a.organisation_id = client_a.id + auth_a.save! + auth_b = Generator.authority(domain: "b-#{RANDOM.hex(4)}.example.com") + auth_b.organisation_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, 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_ORGANISATION, org.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 + _, org, authority, user = build_estate + 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_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 + + 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 + _, org, _, user = build_estate + 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 + end +end diff --git a/spec/helper.cr b/spec/helper.cr index def4d5e6..d71d5b73 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -39,6 +39,9 @@ Spec.after_suite do PlaceOS::Model::GroupPlaylistItem, PlaceOS::Model::GroupPlaylist, PlaceOS::Model::Group, + PlaceOS::Model::Grant, + PlaceOS::Model::Organisation, + PlaceOS::Model::Partner, ].each(&.clear) end diff --git a/spec/organisation_spec.cr b/spec/organisation_spec.cr new file mode 100644 index 00000000..f85d5d48 --- /dev/null +++ b/spec/organisation_spec.cr @@ -0,0 +1,114 @@ +require "./helper" + +module PlaceOS::Model + describe Organisation do + Spec.before_each do + ControlSystem.clear + Zone.clear + Authority.clear + Organisation.clear + Partner.clear + end + + it "saves a partnered org with partner-pays default" do + partner = Generator.partner.save! + org = Generator.organisation(partner: partner).save! + org.persisted?.should be_true + 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.organisation.save! + org.partner_id.should be_nil + org.payer.should eq Organisation::PAYER_ORGANISATION + 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 + 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.organisation(partner: partner_a).save! + + # Same name under a different partner is fine + 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.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.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.organisation_id" do + org = Generator.organisation.save! + authority = Generator.authority(domain: "org-spec.example.com") + authority.organisation_id = org.id + authority.save! + + 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.organisation.save! + authority = Generator.authority(domain: "org-restrict.example.com") + authority.organisation_id = org.id + authority.save! + + expect_raises(Exception, /foreign key/) { org.destroy } + 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.organisation(partner: partner).save! + sibling = Generator.organisation(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.organisation.save! + zone = Generator.zone + zone.organisation_id = org.id + zone.save! + + sys = Generator.control_system + sys.organisation_id = org.id + sys.save! + + 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 new file mode 100644 index 00000000..74a270d5 --- /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 + Organisation.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! + 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.organisation(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/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/authority.cr b/src/placeos-models/authority.cr index 7db1e6fb..3717d9b8 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 Organisation (customer organisation) that owns this domain. + # Nullable while ownership backfill and provisioning are phased in. + attribute organisation_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 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 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..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 @@ -33,6 +34,16 @@ module PlaceOS::Model # Matches will be replaced with a hmac_256(secret, match). attribute filters : Array(String) = -> { [] of String } + # 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 organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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 ############################################################################################### @@ -40,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 41db663d..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" @@ -42,6 +43,15 @@ module PlaceOS::Model # Array of security group ids for room access attribute security_groups : Array(String) = -> { [] of String } + # PPT-526: the Organisation (customer organisation) that owns this system. + # Nullable while ownership backfill and query enforcement are phased in. + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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" # Provide fields for simplifying support @@ -132,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 b65ce340..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" @@ -20,6 +21,15 @@ module PlaceOS::Model attribute last_seen : Time?, converter: Time::EpochConverterOptional, mass_assignment: false attribute online : Bool = false, mass_assignment: false + # PPT-526: the Organisation (customer organisation) that owns this edge node. + # Nullable while ownership backfill and query enforcement are phased in. + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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)] getter x_api_key : String do self.api_key.as(ApiKey).x_api_key.as(String) @@ -130,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/grant.cr b/src/placeos-models/grant.cr new file mode 100644 index 00000000..5907a83b --- /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 "./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 Organisation, + # or an Authority. Authorization resolves by walking UP from the touched + # 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/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 + + table :grants + + SCOPE_PARTNER = "partner" + SCOPE_ORGANISATION = "organisation" + SCOPE_AUTHORITY = "authority" + SCOPE_TYPES = [SCOPE_PARTNER, SCOPE_ORGANISATION, 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 -> 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 + 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 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 (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 + 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/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 + end +end diff --git a/src/placeos-models/module.cr b/src/placeos-models/module.cr index e94f7a80..11cf9898 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 Organisation (customer organisation) that owns this module. + # Nullable while ownership backfill and query enforcement are phased in; + # shared device/service modules referenced by several organisations' systems + # stay NULL until a sharing policy is decided. + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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 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/organisation.cr b/src/placeos-models/organisation.cr new file mode 100644 index 00000000..c47046c0 --- /dev/null +++ b/src/placeos-models/organisation.cr @@ -0,0 +1,92 @@ +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 -> 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 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 Organisation < ::PgORM::Base + include PgORM::Timestamps + + table :organisations + + PAYER_PARTNER = "partner" + PAYER_ORGANISATION = "organisation" + PAYERS = [PAYER_PARTNER, PAYER_ORGANISATION] + + 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 + + # 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 + + before_save :normalize_payer + + validates :name, presence: true + + validate ->(this : Organisation) { + unless PAYERS.includes?(this.payer) + this.validation_error(:payer, "must be one of #{PAYERS.join(", ")}") + 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. + name = this.name + return if name.nil? || name.empty? + 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 organisation with this name already exists") + } + + protected def normalize_payer + self.payer = PAYER_ORGANISATION if self.partner_id.nil? + end + + # 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 organisation. + 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/partner.cr b/src/placeos-models/partner.cr new file mode 100644 index 00000000..eb88516b --- /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 + + # 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 e5dab057..201de4cc 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 Organisation (customer organisation) that owns this trigger + # definition. NULL = shared/cluster-wide definition. Nullable while + # ownership backfill and query enforcement are phased in. + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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 5c186f92..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" @@ -49,6 +50,15 @@ module PlaceOS::Model attribute images : Array(String) = [] of String attribute playlists : Array(String) = [] of String, es_type: "keyword" + # PPT-526: the Organisation (customer organisation) that owns this zone. + # Nullable while ownership backfill and query enforcement are phased in. + attribute organisation_id : UUID?, es_type: "keyword", mass_assignment: false + + # 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? # Association @@ -117,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 ###############################################################################################