@happyvertical/smrt-users 0.51.14 → 0.51.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -21,7 +21,12 @@ are not prerequisites for unrelated user-package work.
21
21
  `emailKey`. `profileId` is a unique cross-package reference: at most one User
22
22
  owns each non-null Profile.
23
23
  - Tenant uses STI and a materialized hierarchy, maximum depth 10.
24
- `createChild()` calculates paths; `moveToParent()` updates all descendants.
24
+ `hierarchyPath`/`hierarchyLevel` are derived from `parentTenantId` by
25
+ `Tenant.save()` on every save (any caller-supplied value is recomputed) and
26
+ descendants are re-materialized when they change; never hand-maintain them.
27
+ Legacy rows are backfilled with `smrt db:materialize-tenant-hierarchy`
28
+ (`materializeTenantHierarchy()`). Tests that need a corrupt path must write it
29
+ with raw SQL (#3036).
25
30
  - Role with `tenantId = null` is available to all tenants; `isSystem` prevents
26
31
  deletion. `inheritsToDescendants` is opt-in. Seed owner/admin/member/viewer
27
32
  through `RoleCollection.seedSystemRoles()` at application initialization.
package/README.md CHANGED
@@ -663,6 +663,62 @@ immediately invalidates every child at the next guard check.
663
663
 
664
664
  Tenants support parent-child trees (max depth 10). Two flags control inheritance: `cascadePermissions` (parent pushes down) and `inheritPermissions` (child accepts). Both must be true for permissions to flow.
665
665
 
666
+ The materialized `hierarchyPath` / `hierarchyLevel` are **derived by the
667
+ framework**: `Tenant.save()` recomputes them from the real `parentTenantId`
668
+ chain on every save — through the collection helpers, a plain
669
+ `tenant.parentTenantId = x; await tenant.save()`, or an STI subclass — and
670
+ re-materializes every descendant when they change. A missing parent, a cycle,
671
+ or a move that would push any descendant past the depth limit throws
672
+ `TenantHierarchyError` before anything is written. Never set them by hand.
673
+ Because a parent link carries authority down the tree, a new or changed
674
+ `parentTenantId` must also be visible under the caller's own tenancy scope
675
+ (the save loads it through `TenantCollection`); run platform-level reparenting
676
+ in `withSystemContext()`.
677
+
678
+ Both hierarchy features — `inheritsToDescendants` and the ancestor-read policy
679
+ below — walk that path and fail closed without it. Rows written before the
680
+ framework maintained it (a correct `parent_tenant_id` but an empty path at level
681
+ 0) must be backfilled once, after `db:migrate`:
682
+
683
+ ```sh
684
+ smrt db:materialize-tenant-hierarchy --dry-run # report what would change
685
+ smrt db:materialize-tenant-hierarchy # apply (idempotent)
686
+ ```
687
+
688
+ Or programmatically: `await materializeTenantHierarchy(db, { dryRun })`. It
689
+ writes only rows that differ (a second run is a no-op), runs in one
690
+ transaction, touches only the two derived columns, and refuses — writing
691
+ nothing — when any tenant's parent chain is broken, listing the offenders.
692
+
693
+ **Upgrading an existing fleet.** Two things change the moment the new package
694
+ is deployed, before any backfill:
695
+
696
+ 1. **Run `--dry-run` first, as a health check.** A tenant whose *real*
697
+ `parent_tenant_id` chain is broken — a missing parent, a cycle, or deeper
698
+ than 10 (older releases accepted explicit `hierarchyLevel`/`hierarchyPath`
699
+ create inputs without checking) — now makes permission resolution for that
700
+ tenant and its whole subtree throw `TenantHierarchyError`
701
+ (`code`: `PARENT_NOT_FOUND`, `CIRCULAR_REFERENCE`, `MAX_DEPTH_EXCEEDED`)
702
+ instead of guessing. The dry run lists exactly these rows; repair their
703
+ `parent_tenant_id` by hand (the backfill refuses to write while any exist),
704
+ then apply.
705
+ 2. **The tenant-override cascade follows the real parent chain.** Resolution
706
+ no longer trusts a stored path it cannot verify, so a never-materialized
707
+ child now receives its real ancestors' `TenantPermissionOverride` rows in
708
+ both directions — DENYs *and* GRANTs (subject to `cascadePermissions` /
709
+ `inheritPermissions`). That is the intended cascade and what the backfill
710
+ produces anyway, but ancestor GRANT overrides that were previously inert on
711
+ such rows become live: audit them before rolling out. `inheritsToDescendants`
712
+ and the ancestor-read policy stay dormant on those rows until the backfill
713
+ runs.
714
+
715
+ Permission resolution works under the `@happyvertical/smrt-tenancy`
716
+ interceptor regardless of which users classes you register as tenant-scoped:
717
+ the resolver's own cross-tenant reads (ancestor tenant overrides, ancestor and
718
+ descendant memberships) run in the framework's system context, keyed by the
719
+ user and tenant being resolved. It returns permissions, never rows; your
720
+ application's reads stay filtered.
721
+
666
722
  ### Read-only ancestor visibility (opt-in, off by default)
667
723
 
668
724
  Membership authority travels DOWN the hierarchy: a direct membership in the
@@ -748,8 +804,12 @@ pass `null` to force it off regardless of configuration.
748
804
 
749
805
  #### Adoption note
750
806
 
751
- This is additive and off by default — no migration is required, and no existing
752
- deployment changes behavior until `permissions.ancestorRead` is declared. When
807
+ This is additive and off by default — no schema migration is required, and no
808
+ existing deployment changes behavior until `permissions.ancestorRead` is
809
+ declared. It does require materialized tenant paths: run
810
+ `smrt db:materialize-tenant-hierarchy` once if your tenants were created before
811
+ the framework maintained `hierarchyPath` (see
812
+ [Hierarchical tenants](#hierarchical-tenants)). When
753
813
  adopting it, declare the **narrowest** role and collection lists that make the
754
814
  ancestor-level list work, and keep `maxDepth` at the smallest value your
755
815
  hierarchy needs. `roles` must name system-role slugs; a tenant-scoped role with
@@ -803,6 +863,8 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
803
863
  | `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
804
864
  | `backfillLegacyUserProfiles` | Transactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership. |
805
865
  | `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
866
+ | `materializeTenantHierarchy` | Idempotently backfill `hierarchy_path`/`hierarchy_level` from `parent_tenant_id` (`{ dryRun }`); refuses a broken chain without writing. CLI: `smrt db:materialize-tenant-hierarchy`. |
867
+ | `planTenantHierarchy` | Pure planner behind the backfill: expected fields, changes, and broken-chain problems for a set of tenant rows. |
806
868
  | `OidcProfileResolver` | Transaction-bound pre-provision hook for application identity reconciliation. |
807
869
  | `OidcProfileOwnerAuthorizer` | Transaction-bound application authorization for binding a first identity to an existing canonical Profile and its sole approved User owner. |
808
870
  | `NormalizedOidcClaims` | Frozen resolver claims with required normalized `email`. |
@@ -842,7 +904,8 @@ for authentication, deduplication, and status semantics.
842
904
  | `ACCESS_REQUEST_CAPABILITIES`, `AccessRequestError` | Operator capability slugs; typed domain error (`error.code`) |
843
905
  | `DEFAULT_ROLE_SLUGS`, `DEFAULT_ROLES`, `DEFAULT_TENANT_POLICY` | System role slugs, role configs, default tenant policy |
844
906
  | `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
845
- | `TenantHierarchyError` | Thrown when hierarchy depth limit is exceeded |
907
+ | `TenantHierarchyError` | Thrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (`code`) |
908
+ | `TenantHierarchyMaterializationError` | Thrown by `materializeTenantHierarchy` when any tenant's parent chain is broken; lists `problems` |
846
909
 
847
910
  ## Dependencies
848
911