Skip to content

feat: PPT-2701 organisation reach enforcement (multi-tenancy) - #451

Draft
camreeves wants to merge 5 commits into
masterfrom
PPT-526-tenancy
Draft

camreeves wants to merge 5 commits into
masterfrom
PPT-526-tenancy

Conversation

@camreeves

@camreeves camreeves commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Enforcement half of multi-tenancy (PPT-2701, under PPT-526). Builds on the data layer in PlaceOS/models#332 through shard.override.yml; the spec migrator clones that branch too. Draft until the set has been through a clean install on the test cluster.

What this does

Every request now resolves the caller's reach once, in Utils::Tenancy, and every list, lookup and mutation on the estate stays inside it.

reach who sees
cluster admin or support on the management partner's staff organisation everything, as today
partner admin or support on a partner's staff organisation (organisations.partner_staff) every organisation under that partner
organisation everyone else their own organisation

Live grants (grants table) widen the organisation set without changing the level. Rows with no organisation are visible to cluster reach only. A domain with no organisation reaches nothing.

PLACE_TENANCY_ENFORCE (default off) switches between refusing and logging what would have been refused. With it off the API behaves exactly as before this PR, so it can ship dark; the log lines (tenancy would refuse) show every cross-organisation habit before the flip.

Where it applies

  • Lists: domains, zones (and tags), systems (and email lookup), modules, triggers, edges, users, api keys, OAuth applications, auth sources, settings and metadata parents.
  • Lookups: a row outside reach answers 404, so a foreign id looks like an unknown one (the users controller already did this).
  • Writes: new zones take their parent's organisation or the caller's; systems take their zones' organisation and refuse zones from two organisations; modules follow their system; edges follow their user's domain; a module from another organisation cannot be attached to a system.
  • Cluster-only: brokers, repository and driver changes, cluster, build jobs, schemas, driver settings, the unscoped settings list, trigger templates (triggers with no system; readable by everyone), fleet-wide edge monitoring.
  • Websocket: /control commands against a system outside reach answer module_not_found.
  • Last organisation admin: an organisation admin cannot delete their organisation's last admin (403); cluster staff can, which is how a whole domain is torn down. The model's cluster-wide guard is unchanged.
  • List filters: organisation_id on the domain, zone, system, module, trigger, edge and user lists narrows a cluster or partner caller to one organisation (it must be within reach).
  • New routes: /organisations (with GET /organisations/current for the caller's reach and POST /organisations/:id/claim to adopt an unowned zone tree), /partners, /grants. Ownership columns stay mass_assignment: false; these routes and the query parameters on domain and zone create are the only write paths.

Verification

  • spec/controllers/tenancy_spec.cr: a four-organisation fixture (management partner on localhost, a partner with a staff organisation and a client, a direct customer, an unowned tree) proving cross-organisation reads and writes are refused, partner staff reach their clients, cluster admins reach everything, grants widen and expire, log-only mode only logs, cluster-only routes refuse organisation admins, and claim adopts a tree.
  • The existing controller specs run with enforcement off and are unchanged. The tenancy fixture tears itself down after each example: the harness clears tables only once per suite, and leaked rows push later unfiltered list assertions past the first page (the cause of the zones and public-events failures seen on earlier CI runs).
  • crystal tool format --check clean; ameba clean on the new files.
  • OPENAPI_DOC.yml regenerated. It adds the eight new paths (/organisations, /organisations/current, /organisations/{id}, /organisations/{id}/claim, /partners, /partners/{id}, /grants, /grants/{id}); the rest of that diff is the generator's path ordering.

Review notes

  • Partner reach is structural (partner_staff) rather than per-user grants, so NTT's staff do not each need a grant row; grants remain for exceptions and time-boxed access.
  • The management partner's support users get cluster reach structurally. The August design wanted an explicit seeded grant; membership of the staff organisation is the explicit, auditable thing here, and a grant-based version can replace it without touching the controllers.
  • Trigger definitions with no control_system_id are templates (16 of 17 on placeos-dev), so they stay cluster-managed and readable by all.

Plan: https://gist.github.com/camreeves/3fabfff92bcdef77a1dcb4b8d8ebfe7b

Resolve the caller's reach once per request (cluster, partner or
organisation, widened by live grants) and keep every estate list, lookup
and mutation inside it. PLACE_TENANCY_ENFORCE switches between refusing
and logging what would be refused. Adds organisations, partners and
grants routes, builds against the models PPT-526-partner-client branch.
@github-actions github-actions Bot added the type: enhancement new feature or request label Oct 7, 2026
A partner-less organisation tripped the non-nilable partner association;
resolve the partner through its id. Regenerate OPENAPI_DOC.yml for the
organisations, partners and grants routes.
Lets a caller with cluster or partner reach narrow domains, zones,
systems, modules, triggers, edges and users to one organisation. The
organisation must be within reach.
Rows left behind by other spec files can push the fixture's rows past the
default page, which is what failed in CI.
…ning tenancy fixture

An organisation admin cannot remove their organisation's last admin;
cluster staff can, which is how a domain is torn down. The tenancy spec
fixture now tears itself down after each example: the harness clears
tables only once per suite, and leaked rows pushed later unfiltered list
assertions past the first page. Tracks models at e21d790.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement new feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant