feat(orgs): bulk org/site import + organization_external_links (#3242) - #3273
Conversation
…3242) - New organization_external_links table: shape-1 RLS (enabled+forced, same migration), composite (org_id, partner_id) FK, per-partner unique (partner_id, system, external_id), backfill from accounting_* columns. - Registered in CORE_ORG_CASCADE_DELETE_ORDER and CORE_TENANT_EXPORT_POLICY. - services/orgImport: preview/commit pipeline with source seam, name-match acknowledgement, soft-deleted reactivation opt-in, per-row partial success. - QuickBooks importer DUAL-WRITES the link row alongside the legacy columns and reads the union, so post-PR orgs keep matching on re-import. - POST /orgs/import/preview and POST /orgs/import (partner/system + orgs:write + MFA), 1000-row cap, audit fan-out. - Unit tests (service, routes, QB importer) + RLS/backfill integration suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CSV upload (picker + drag-and-drop) -> client-side RFC-4180 parse (hand-rolled, no new dependency) -> column mapping with auto-guess -> preview table with per-row status badges -> commit through runAction with partial-success reporting. name-match rows unchecked by default; soft-deleted matches are an explicit reactivate opt-in; conflict rows not selectable. Mounted on the Organizations settings page behind a Bulk import toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
0e5bdd0
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4f852c5e.breeze-9te.pages.dev |
| Branch Preview URL: | https://feat-3242-org-external-links.breeze-9te.pages.dev |
- Use shared isPgUniqueViolation (utils/pgErrors) in orgImport and the QB importer: Drizzle wraps postgres.js errors so a top-level .code check missed every race-recovery path. - Identity pinning: commit rows carry expectedOrganizationId from preview and are rejected if the re-derived match resolves to a different organization. - Web select-all only toggles create/link-match rows — never bulk-acknowledges name-matches or bulk-reactivates soft-deleted orgs. - i18n: mark dynamic t() keys with /* i18n-dynamic */, translate remaining bulkOrgImport strings, and bump settings.json duplicate baselines for the genuine cognates (Status de/pt, Site fr) with justification comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review run: /code-review at high effort (8 finder angles: 3 correctness — line-by-line diff scan, removed-behavior audit, cross-file trace; reuse; simplification; efficiency; altitude; conventions). Findings: 3 consequential raised → all addressed in 0e5bdd0; 0 outstanding.
Conventions angle came back clean (migration idempotency, cascade/export registrations, runAction usage, i18n all verified compliant). Remaining reuse/altitude notes (third slug-helper copy in Tests: apps/api affected suites green single-run ( Live-DB contract suites ( Status: review-clean, awaiting CI + maintainer merge. The |
|
CI update: both runs finished green — PR run 31265784182 (required jobs incl. Test API / Test Web) and dispatched run 31265785264 (workflow_dispatch, covering the live-DB contract suites: rls-coverage, tenantCascade, tenant-export-policy, erasure roundtrip, and the new orgExternalLinksRls forge/backfill suite). Nothing outstanding from my side — awaiting maintainer merge. |
Closes #3243. Part of epic #3249.
Implements the plan in
docs/superpowers/plans/open/2026-08-08-partner-api-provisioning-writes.md(on thedocs/rmm-migration-guidesbranch): lets a partner service principal create organizations, sites, and enrollment keys unattended, so RMM-migration scripts and integrations can provision tenancy without an MFA'd human JWT.What changed
Scopes (
services/partnerServicePrincipalScopes.ts)organizations:write,sites:write,enrollment-keys:write, validated by the existingvalidatePartnerServicePrincipalScopesand mintable only through the MFA-gated partner principal management routes.hasSatisfiedMfais untouched, per the issue thread's explicit rejection of principal-type branching.Write-surface allowlist test (
routes/partnerApi/writeSurface.test.ts) — written FIRST, per the condition attached to the #3243 decisionpartnerApiRoutes; the set of non-GET routes must equal an explicit allowlist constant. Seeded empty, verified green on the pre-change tree; its first run against the new routes failed with exactlyPOST /organizations,POST /sites,POST /enrollment-keysbefore they were allowlisted (evidence the guard works). A canary test proves a fourth unlisted write route fails the suite.Auth middleware (
middleware/partnerApiAuth.ts)/orgs/*write routes).organizationsINSERT trigger takes the same lock exclusive (2026-07-21-partner-export-canonical-org-mutations.sql). An insert from a nested system transaction on a second pooled connection would wait on our own request's shared lock — an application-level self-deadlock. Writes also take no export snapshot, so the held-transaction consistency machinery doesn't apply to them.min(key limit, 120)/hour, separate Redis key, 429 +Retry-Afteron exhaustion.recordMachineUsecontinues to audit every write request (success and failure) as the service principal.Routes (
routes/partnerApi/provisioning.ts)POST /partner-api/organizations—organizations:write.partnerIdcomes only from the principal (body value stripped). Runs in a bounded system context (a new org's id can't pass the id-keyed RLS insert policy under partner scope — same escape as the human route), takes the partner lock exclusive up front so themaxOrganizationsquota check is race-free, maps slug 23505 to 409, and enforces the cap with a specific 409 (partner_provisioning_org_limit_reached). Status restricted toactive/trial(creating suspended/churned tenants unattended is not a workload).POST /partner-api/sites—sites:write.orgIdoutside the principal's accessible set → 403. Insert runs in a partner-scoped bounded context (userId: null), so RLS enforces org access a second time.POST /partner-api/enrollment-keys—enrollment-keys:write. Mirrors the human schema (.strict(),ttlMinutesXORexpiresAt,maxUsage1–100000), rejects TTLs above the partner cap on both expiry paths via the sharedassertTtlWithinCap, verifiessiteIdbelongs to the org, stores only the hash,createdBy: null. Raw key returned exactly once at the top level of the 201 body.revision, andsafelyExportDefinitioninspection, sodtoSafety/exportSafetyapply to writes exactly as to reads.organization.create/site.create/enrollment_key.createevents attributed to the service principal (actorType: 'api_key', key id), never a human.UI (
apps/web/.../PartnerServicePrincipalsPage.tsx) — write scopes selectable but excluded from the default selection.Docs —
reference/api.mdxgains a Partner API section (all routes + scopes);reference/api-keys.mdxgains the partner-principal scope table with the opt-in warning.Tests
writeSurface.test.ts— allowlist + canary (first run failed pre-allowlist, see above).provisioning.test.ts— 21 cases: scope/auth (401/403), body-partnerIdignored, org-access 403s,maxOrganizationsboundary (at cap 409 / below cap 201), TTL-cap 400, XOR 400,.strict()unknown-key 400, site-mismatch 400, raw-key-once + hash-stored +createdBynull, DTO revision shape, partner-contextuserId: null, audit attribution for all three routes.partnerApiAuth.test.ts— 5 new cases for the write branch: no held context duringnext(), write bucket key/limit, write 429 + Retry-After, machine-use audit on write success and failure. All 34 pre-existing cases unchanged and green.partnerApiAuth, allroutes/partnerApi/*,partnerServicePrincipals,enrollmentKeys*,enrollmentKeySecurity).tsc --noEmitclean forapps/api.Not in this PR
apps/docs/.../migration/toolkit.mdxrewrite (plan Task 7): that file lives on the unmergeddocs/rmm-migration-guidesbranch (PR docs(migration): RMM-to-Breeze migration guides #3250), not onmain— its auth section and Recipe 1/2 should be updated there (or in a follow-up) once both PRs land.🤖 Generated with Claude Code