Skip to content

feat: PPT-526 multi-tenancy data layer (partners, organisations, ownership, grants) - #332

Draft
camreeves wants to merge 6 commits into
masterfrom
PPT-526-partner-client
Draft

camreeves wants to merge 6 commits into
masterfrom
PPT-526-partner-client

Conversation

@camreeves

@camreeves camreeves commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Data layer for multi-tenancy (PPT-526). Records who owns what; enforcement comes separately in rest-api (PPT-2701).

What this adds

Two tables above Domain and one ownership column below it.

  • partners and organisations. A Partner is whoever sells to and manages a customer (NTT), or the PlaceOS management partner. An Organisation is the customer. organisations.partner_id is nullable for self-managed customers. payer records whether the partner or the organisation is billed.
  • organisation_id on authority, zone, sys, mod, trigger, edge and broker. It is mass_assignment: false on every model, so no API payload can reassign ownership. Every FK is ON DELETE RESTRICT: deleting a partner or organisation with anything under it fails rather than cascading through an estate.
  • grants: a (user, scope_type, scope_id, permissions, expires_at, granted_by) tuple. Grant.resolve(user, authority) walks authority, organisation, partner and ORs the live grants. Permissions reuse the existing Permissions flags enum. Nothing reads it yet.
  • A real UNIQUE index on authority.domain. Legacy duplicates are renamed <domain>.duplicate.<id> first, oldest row keeps the name, so the index can never half-apply.
  • Dormant partial unique indexes on (organisation_id, name) for zone, system, edge and broker. They only bite once rows are owned and enforcement flips model uniqueness from global to per organisation.
  • An idempotent backfill. Creates the management partner, one organisation per existing domain, then walks each domain's org_zone tree assigning zones, systems, modules and triggers. A tree claimed by more than one organisation is left NULL rather than guessed. Brokers are left NULL on purpose (cluster infrastructure).

Added since opening

  • organisations.partner_staff: a partner's own staff organisation, whose admins and support users reach every organisation under that partner (rest-api#451 reads it).
  • Zone, system, edge and broker names are unique within an organisation rather than across the cluster; rows with no organisation stay unique among themselves.
  • The last-admin guard stays cluster-wide. A per-organisation guard in the model would block tearing down a customer's whole domain (its last admin goes with it); rest-api#451 enforces the organisation-level rule in the users delete route instead, where the caller's reach is known.

Rollout notes

  • Nothing on master reads these columns, so merging is safe for every environment on nightly.
  • The backfill only fills NULLs. Where domains share an org zone (three do on placeos-dev), create the organisation and point the domains at it before running the migration and the tree resolves. Dry run against a placeos-dev dump: tasks/PPT-526/rollout/dev-dryrun-2026-10-07.md in the workspace.
  • Migration ids are 20261007100500000, 20261007100600000, 20261007100700000. If anything later than that merges first, renumber before merging this. micrate autocommits per statement.
  • Adding columns breaks cached query plans on already-connected services until they restart; roll the services with the migration.

Verification

  • 820 examples, 0 failures, 0 errors (isolated-compose run --rm test, fresh database).
  • crystal tool format --check and ameba clean.
  • tasks/PPT-526/backfill-verify.sh: 24 assertions against realistic and adversarial fixtures (shared trees, duplicate names, dangling references, double run).

Design notes and the review history are in the workspace under tasks/PPT-526/ (STAGE2-PLAN.md, STAGE3-DESIGN.md). The plan this belongs to: https://gist.github.com/camreeves/3fabfff92bcdef77a1dcb4b8d8ebfe7b

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.
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).
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.
…ons 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.
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Oct 7, 2026
…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.
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.

This branch has not been deployed

No deployments
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