From 9e7a52d2689635d68b83a4d6754155041fe96449 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 15:29:48 +1100 Subject: [PATCH 1/4] feat: PPT-526 seed the management partner and own the placeholder estate start creates the management partner and its staff organisation and attaches the first domain to it, so the seeded admin has cluster reach once tenancy enforcement is on. Placeholder rows are created owned by that organisation. Builds and clones migrations from the models tenancy branch until models #332 merges. --- Dockerfile | 3 ++- shard.override.yml | 5 +++++ src/sam.cr | 4 +++- src/tasks/entities.cr | 44 +++++++++++++++++++++++++++++++++++-- src/tasks/initialization.cr | 3 ++- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0708a3a..ffeb5c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,7 +81,8 @@ RUN for binary in /app/bin/* /usr/bin/pg_dump /usr/bin/pg_restore /usr/bin/psql; xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;'; \ done -RUN git clone https://github.com/PlaceOS/models +# PPT-526: the tenancy migrations live on this branch until models #332 merges +RUN git clone --branch PPT-526-partner-client https://github.com/PlaceOS/models # Create tmp directory with proper permissions RUN rm -rf /tmp && mkdir -p /tmp && chmod 1777 /tmp diff --git a/shard.override.yml b/shard.override.yml index 9c17593..a8bb7aa 100644 --- a/shard.override.yml +++ b/shard.override.yml @@ -1,4 +1,9 @@ dependencies: + # PPT-526: build against the multi-tenancy data layer until models #332 merges + placeos-models: + github: placeos/models + branch: PPT-526-partner-client + retriable: github: Sija/retriable.cr db: diff --git a/src/sam.cr b/src/sam.cr index 06aed5e..b864ef1 100644 --- a/src/sam.cr +++ b/src/sam.cr @@ -275,7 +275,9 @@ namespace "create" do desc "Creates a representative set of documents in PostgreSQL DB" task "placeholders" do - PlaceOS::Tasks.create_placeholders + # placeholders belong to the staff organisation when one exists + staff = PlaceOS::Model::Organisation.where(partner_staff: true).first? + PlaceOS::Tasks.create_placeholders(staff.try(&.id)) end desc "Creates an authority" diff --git a/src/tasks/entities.cr b/src/tasks/entities.cr index 6f46dc1..b222b5f 100644 --- a/src/tasks/entities.cr +++ b/src/tasks/entities.cr @@ -35,6 +35,41 @@ module PlaceOS::Tasks::Entities raise e end + # The platform operator's partner and staff organisation. The first domain + # belongs to that organisation, so its admins have cluster reach once + # tenancy enforcement is on. + def create_management_organisation( + authority : Model::Authority, + partner_name : String = "PlaceOS", + ) : Model::Organisation + partner = upsert_document(Model::Partner.where(management: true)) do + Log.info { {message: "creating management Partner", name: partner_name} } + Model::Partner.new(name: partner_name, description: "Platform operator (management partner)").tap do |new_partner| + new_partner.management = true + end + end + + organisation = upsert_document(Model::Organisation.where(partner_id: partner.id, partner_staff: true)) do + Log.info { {message: "creating staff Organisation", name: authority.name} } + Model::Organisation.new(name: "#{partner_name} staff", description: "Staff of the management partner").tap do |new_org| + new_org.partner_id = partner.id + new_org.partner_staff = true + new_org.payer = Model::Organisation::PAYER_ORGANISATION + end + end + + if authority.organisation_id.nil? + authority.organisation_id = organisation.id + authority.save! + Log.info { {message: "attached Authority to staff Organisation", authority: authority.id, organisation: organisation.id.to_s} } + end + + organisation + rescue e + log_fail("Organisation", e) + raise e + end + def create_interface( name : String, folder_name : String, @@ -141,7 +176,7 @@ module PlaceOS::Tasks::Entities raise e end - def create_placeholders + def create_placeholders(organisation_id : UUID? = nil) version = UUID.random.to_s.split('-').first private_repository_uri = "https://github.com/placeos/private-drivers" @@ -201,6 +236,7 @@ module PlaceOS::Tasks::Entities Model::Zone.new.tap do |zone| zone.name = "Zone-#{tag}-#{version}" zone.tags = Set{tag} + zone.organisation_id = organisation_id end end end @@ -213,7 +249,9 @@ module PlaceOS::Tasks::Entities zones[2].save! control_system = upsert_document(Model::ControlSystem.all) do - Model::ControlSystem.new(name: "System-#{version}") + Model::ControlSystem.new(name: "System-#{version}").tap do |sys| + sys.organisation_id = organisation_id + end end upsert_document(Model::Settings.for_parent(control_system.id.as(String))) do @@ -227,6 +265,7 @@ module PlaceOS::Tasks::Entities mod = upsert_document(Model::Module.where(driver_id: driver.id.as(String), control_system_id: control_system.id.as(String))) do Model::Generator.module(driver: driver, control_system: control_system).tap do |new_module| new_module.custom_name = "Module-#{version}" + new_module.organisation_id = organisation_id end end @@ -241,6 +280,7 @@ module PlaceOS::Tasks::Entities trigger_description = "An automatically generated Trigger." new_trigger = Model::Trigger.new(name: trigger_name, description: trigger_description) new_trigger.control_system = control_system + new_trigger.organisation_id = organisation_id new_trigger end diff --git a/src/tasks/initialization.cr b/src/tasks/initialization.cr index ad2199e..0bbf5e8 100644 --- a/src/tasks/initialization.cr +++ b/src/tasks/initialization.cr @@ -33,6 +33,7 @@ module PlaceOS::Tasks::Initialization start_lock.synchronize do authority = Entities.create_authority(name: application_base, domain: application_base, config: metrics_config) + organisation = Entities.create_management_organisation(authority) Entities.create_user(authority: authority, name: username, email: email, password: password, sys_admin: true) Entities.create_application(authority: authority, name: application_name, base: application_base) @@ -53,7 +54,7 @@ module PlaceOS::Tasks::Initialization unless PlaceOS::Tasks.production? || PlaceOS::SKIP_PLACEHOLDERS Log.info { "creating placeholder documents" } - Entities.create_placeholders + Entities.create_placeholders(organisation.id) end end end From 0a66f58c910f8a4dc4d0d186dd85b6cfafbfba23 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 6 Nov 2026 15:04:46 +1100 Subject: [PATCH 2/4] chore: PPT-526 track the rebased models tenancy branch at b502009 Picks up pg-orm 2.5.3, which models now requires. Co-Authored-By: Claude Opus 5.5 --- shard.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shard.lock b/shard.lock index 7eb8504..23a9d0b 100644 --- a/shard.lock +++ b/shard.lock @@ -143,7 +143,7 @@ shards: pg-orm: git: https://github.com/spider-gazelle/pg-orm.git - version: 2.5.2 + version: 2.5.3 place_calendar: git: https://github.com/placeos/calendar.git @@ -153,9 +153,9 @@ shards: git: https://github.com/place-labs/log-backend.git version: 0.13.0 - placeos-models: + placeos-models: # Overridden git: https://github.com/placeos/models.git - version: 9.123.3 + version: 9.123.4+git.commit.b50200942c7e6bf30d999c1f500ce1375408edc0 sam: git: https://github.com/imdrasil/sam.cr.git From a0a409d1ad935f5e1956e71a9b401368fe416d2b Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 6 Nov 2026 15:04:46 +1100 Subject: [PATCH 3/4] test: PPT-526 migrate the spec database from the models tenancy branch The spec migrator cloned models master, so specs ran without the tenancy columns the seeding code writes. Co-Authored-By: Claude Opus 5.5 --- spec/migration/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/migration/Dockerfile b/spec/migration/Dockerfile index 11531ab..76b4070 100644 --- a/spec/migration/Dockerfile +++ b/spec/migration/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /app COPY . /app # NOTE:: add `--branch my-new-model-branch` if you want to test a particular branch -RUN git clone https://github.com/PlaceOS/models +# PPT-526: the tenancy migrations live on this branch until models #332 merges +RUN git clone --branch PPT-526-partner-client https://github.com/PlaceOS/models RUN mv ./models/migration/db ./db ENV PATH /app/bin:$PATH From e156c1c7f91690a1f1451fd778ddbe91ed7cf747 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 6 Nov 2026 15:11:07 +1100 Subject: [PATCH 4/4] feat: PPT-526 move a backfilled first domain into the staff organisation On a cluster that predates tenancy the backfill migration runs before `start` and gives the first domain an organisation of its own, so the seeding never attached it to the staff organisation and its admins would have no cluster reach once enforcement is on. While that organisation is still as the backfill created it (no partner, no other domain, the backfill's description), move everything it owns and the grants scoped to it into the staff organisation and remove it, in one transaction. Organisations an operator has changed are left alone. Co-Authored-By: Claude Opus 5.5 --- spec/entities_spec.cr | 92 +++++++++++++++++++++++++++++++++++++++++++ spec/spec_helper.cr | 3 +- src/tasks/entities.cr | 44 +++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 spec/entities_spec.cr diff --git a/spec/entities_spec.cr b/spec/entities_spec.cr new file mode 100644 index 0000000..2966037 --- /dev/null +++ b/spec/entities_spec.cr @@ -0,0 +1,92 @@ +require "./spec_helper" +require "../src/tasks/entities" + +module PlaceOS::Tasks + # The organisation the backfill migration creates for a domain that has none + def self.backfilled_organisation(authority : Model::Authority) : Model::Organisation + organisation = Model::Organisation.new( + name: "#{authority.name} (#{authority.domain})", + description: "Auto-created from authority #{authority.id} during PPT-526 organisation backfill", + ).save! + authority.organisation_id = organisation.id + authority.save! + organisation + end + + def self.owned_zone(organisation : Model::Organisation) : Model::Zone + zone = Model::Zone.new(name: "zone-#{Random::Secure.hex(4)}", tags: Set{"org"}) + zone.organisation_id = organisation.id + zone.save! + end + + def self.organisation_grant(organisation : Model::Organisation) : Model::Grant + user = Model::Generator.user.save! + Model::Generator.grant(user, Model::Grant::SCOPE_ORGANISATION, organisation.id.to_s) + end + + describe Entities do + describe "create_management_organisation" do + it "seeds the management partner and attaches an unowned domain to its staff organisation" do + authority = Model::Generator.authority("https://first.example.com").save! + + organisation = Entities.create_management_organisation(authority) + + organisation.partner_staff.should be_true + organisation.partner.not_nil!.management.should be_true + Model::Authority.find!(authority.id.as(String)).organisation_id.should eq organisation.id + end + + it "is idempotent" do + authority = Model::Generator.authority("https://first.example.com").save! + + first = Entities.create_management_organisation(authority) + second = Entities.create_management_organisation(Model::Authority.find!(authority.id.as(String))) + + second.id.should eq first.id + Model::Partner.where(management: true).count.should eq 1 + Model::Organisation.where(partner_staff: true).count.should eq 1 + end + + it "moves a backfilled estate into the staff organisation" do + authority = Model::Generator.authority("https://first.example.com").save! + backfilled = backfilled_organisation(authority) + zone = owned_zone(backfilled) + grant = organisation_grant(backfilled).save! + + organisation = Entities.create_management_organisation(authority) + + authority.organisation_id.should eq organisation.id + Model::Authority.find!(authority.id.as(String)).organisation_id.should eq organisation.id + Model::Zone.find!(zone.id.as(String)).organisation_id.should eq organisation.id + Model::Grant.find!(grant.id.not_nil!).scope_id.should eq organisation.id.to_s + Model::Organisation.find?(backfilled.id.not_nil!).should be_nil + end + + it "leaves an organisation an operator has changed" do + authority = Model::Generator.authority("https://first.example.com").save! + backfilled = backfilled_organisation(authority) + backfilled.description = "Acme, merged by hand" + backfilled.save! + zone = owned_zone(backfilled) + + Entities.create_management_organisation(authority) + + Model::Authority.find!(authority.id.as(String)).organisation_id.should eq backfilled.id + Model::Zone.find!(zone.id.as(String)).organisation_id.should eq backfilled.id + end + + it "leaves an organisation that owns other domains" do + authority = Model::Generator.authority("https://first.example.com").save! + backfilled = backfilled_organisation(authority) + other = Model::Generator.authority("https://second.example.com") + other.organisation_id = backfilled.id + other.save! + + Entities.create_management_organisation(authority) + + Model::Authority.find!(authority.id.as(String)).organisation_id.should eq backfilled.id + Model::Organisation.find?(backfilled.id.not_nil!).should_not be_nil + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 41cb4df..fdeae80 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -8,7 +8,8 @@ PgORM::Database.configure { |_| } def clear_tables PgORM::Database.exec_sql <<-SQL TRUNCATE "asset", "asset_type", "asset_category", "metadata", "zone", - "booking_instances", "bookings", "tenants", "shortener", "user", "authority", "history" CASCADE + "booking_instances", "bookings", "tenants", "shortener", "user", "authority", "history", + "grants", "partners" CASCADE SQL end diff --git a/src/tasks/entities.cr b/src/tasks/entities.cr index b222b5f..6c90a33 100644 --- a/src/tasks/entities.cr +++ b/src/tasks/entities.cr @@ -35,9 +35,16 @@ module PlaceOS::Tasks::Entities raise e end + # Tables whose rows an organisation owns through `organisation_id` + OWNED_TABLES = {"authority", "zone", "sys", "mod", "trigger", "edge", "broker"} + # The platform operator's partner and staff organisation. The first domain # belongs to that organisation, so its admins have cluster reach once # tenancy enforcement is on. + # + # On a cluster that predates tenancy the backfill migration has already + # given the first domain an organisation of its own. While that organisation + # is untouched, its estate moves into the staff organisation. def create_management_organisation( authority : Model::Authority, partner_name : String = "PlaceOS", @@ -62,6 +69,10 @@ module PlaceOS::Tasks::Entities authority.organisation_id = organisation.id authority.save! Log.info { {message: "attached Authority to staff Organisation", authority: authority.id, organisation: organisation.id.to_s} } + elsif backfilled = backfilled_organisation(authority) + adopt_estate(from: backfilled, into: organisation) + authority.organisation_id = organisation.id + Log.info { {message: "moved the backfilled estate into the staff Organisation", authority: authority.id, from: backfilled.id.to_s, organisation: organisation.id.to_s} } end organisation @@ -70,6 +81,39 @@ module PlaceOS::Tasks::Entities raise e end + # The organisation the backfill migration created for this authority, while + # it is still as created: no partner, no other domain and the backfill's own + # description. Anything an operator has since changed is left alone. + protected def backfilled_organisation(authority : Model::Authority) : Model::Organisation? + organisation_id = authority.organisation_id + return unless organisation_id + + organisation = Model::Organisation.find?(organisation_id) + return unless organisation + return unless organisation.partner_id.nil? + return unless organisation.description == "Auto-created from authority #{authority.id} during PPT-526 organisation backfill" + return unless organisation.authorities.count == 1 + + organisation + end + + # Moves every row and grant owned by `from` to `into`, then removes `from` + protected def adopt_estate(from : Model::Organisation, into : Model::Organisation) : Nil + from_id = from.id.to_s + into_id = into.id.to_s + + PgORM::Database.transaction do |tx| + OWNED_TABLES.each do |table| + tx.connection.exec(%(UPDATE "#{table}" SET organisation_id = $1::uuid WHERE organisation_id = $2::uuid), args: [into_id, from_id]) + end + tx.connection.exec( + %(UPDATE "grants" SET scope_id = $1 WHERE scope_type = $2 AND scope_id = $3), + args: [into_id, Model::Grant::SCOPE_ORGANISATION, from_id], + ) + from.destroy + end + end + def create_interface( name : String, folder_name : String,