@happyvertical/smrt-users 0.51.9 → 0.51.10

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
@@ -27,6 +27,14 @@ are not prerequisites for unrelated user-package work.
27
27
  through `RoleCollection.seedSystemRoles()` at application initialization.
28
28
  - Group roles apply only in their own tenant. Use `getGroupIdsForTenant()`,
29
29
  never cross-tenant `getGroupIds()`, for authorization.
30
+ - Upward visibility is opt-in and read-only: `permissions.ancestorRead`
31
+ ({ roles, collections, maxDepth? }) lets a DESCENDANT membership contribute
32
+ declared `<collection>.read` at an ancestor, only when no membership resolved
33
+ there, intersected with BOTH the role's own catalog grants and the
34
+ principal's effective permissions in that descendant, and only for SYSTEM
35
+ roles (a tenant-scoped role sharing a declared slug is ignored). Off by default, never
36
+ write, never lateral, and never row visibility — sibling rows stay scoped by
37
+ tenancy/RLS. Read the permissions module before changing it.
30
38
  - Membership DENY always wins. Direct inactive membership blocks inherited
31
39
  authority; a direct active membership pins resolution instead of unioning it
32
40
  with ancestors. Read the permissions module before changing these rules.
package/README.md CHANGED
@@ -646,6 +646,100 @@ returned by `resolvePermissions()` and `SessionService.loadSessionContext()`.
646
646
 
647
647
  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.
648
648
 
649
+ ### Read-only ancestor visibility (opt-in, off by default)
650
+
651
+ Membership authority travels DOWN the hierarchy: a direct membership in the
652
+ tenant being resolved, or — per role, via `inheritsToDescendants` — the nearest
653
+ active ancestor membership. Nothing a user holds on a DESCENDANT contributes
654
+ anything at an ancestor, so a principal whose only membership is on a child
655
+ tenant resolves to **no permissions** at the root. That is the safe default and
656
+ it stays the default.
657
+
658
+ Some hierarchies legitimately need the other direction for *reading*: a
659
+ network-level list that members of the network's child publications are meant
660
+ to see. Declare it explicitly:
661
+
662
+ ```typescript
663
+ // smrt.config.ts
664
+ export default defineConfig({
665
+ packages: {
666
+ users: {
667
+ permissions: {
668
+ ancestorRead: {
669
+ // Descendant SYSTEM role slugs allowed to contribute upward.
670
+ // Exact match, and only `tenantId: null, isSystem: true` roles
671
+ // (what `seedSystemRoles()` creates) can match.
672
+ roles: ['member', 'editor'],
673
+ // Collections whose `read` may travel up. `*` matches any run of
674
+ // characters and may appear anywhere (`'site_*'`, `'*_pages'`);
675
+ // `'*'` alone means every collection the declared role can read.
676
+ collections: ['publications', 'tenants'],
677
+ // Hierarchy hops from the membership up to the tenant being
678
+ // resolved. Default 1 (immediate parent only).
679
+ maxDepth: 2,
680
+ },
681
+ },
682
+ },
683
+ },
684
+ });
685
+ ```
686
+
687
+ With that declared, `resolvePermissions(user, networkRootId)` for a
688
+ publication-only `member` returns `publications.read` and `tenants.read` — and
689
+ nothing else. The bounds are hard:
690
+
691
+ | Rule | Behavior |
692
+ |---|---|
693
+ | Default | Off. Undeclared, malformed, or empty-on-either-axis policies resolve exactly as before. |
694
+ | Action | `read` only (`list`/`get` normalize to `read`). `create`/`update`/`delete`/custom actions can never travel upward. |
695
+ | Escalation | Intersected with **both** the declared role's own catalog grants **and** the principal's effective permissions in the contributing tenant. The role bound means a descendant administrator cannot widen the contribution with a tenant GRANT, a group role, or a membership GRANT; the effective bound means a DENY that removed the permission at home removes it at the ancestor too. |
696
+ | Role identity | Only a governed SYSTEM role (`tenantId` null, `isSystem: true`) can match a declared slug. Slugs are not unique across a hierarchy, so a tenant-scoped custom role named `member` contributes nothing — a descendant's administrator cannot mint its way into the allow-list. |
697
+ | Direction | Strictly upward, to verified ancestors only. Siblings share no ancestor relationship and are unreachable. |
698
+ | Precedence | Applies only when NO membership authorized the tenant. A direct membership (even inactive) still pins resolution; an ancestor tenant-level DENY still subtracts. |
699
+ | Hierarchy | The materialized `hierarchyPath` is verified link-by-link against real `parentTenantId` rows; stale, over-deep, or inconsistent paths fail closed. |
700
+ | Bypass | Super-admin and system-context bypass are unchanged. |
701
+
702
+ **This grants the operation, not the rows.** An ancestor-read grant authorizes
703
+ `publications.read` *at the ancestor*. It is not visibility of any sibling
704
+ tenant's rows: row scoping remains with the `@happyvertical/smrt-tenancy`
705
+ interceptor and the generated Postgres RLS policies, which still bind reads to
706
+ the tenant the context is entered with. A member of publication A authorized at
707
+ the network root still cannot read publication B's rows.
708
+
709
+ Resolution happens inside `PermissionResolver.resolvePermissions()`, the single
710
+ point `SessionService.loadSessionContext()`,
711
+ `withPrincipalPermissionContext()`, `assertOperationPermission()`, the
712
+ generated REST/MCP surfaces, and the published `smrt.permissions` RLS variable
713
+ all flow through — so every consumer sees one answer. Resolution is uncached:
714
+ a membership, role, or policy change takes effect on the next resolution, and
715
+ there is no permission cache to invalidate.
716
+
717
+ **Known limitation.** The grant carries no membership, so
718
+ `PermissionResolutionResult.membershipId` and
719
+ `loadSessionContext().tenantAuthorization.membershipId` stay `null`. A surface
720
+ that requires a non-empty `membershipId` for a required-tenant session — such as
721
+ `@happyvertical/smrt-app-runtime`'s deployed runtime — therefore still refuses
722
+ an ancestor-read-only principal (it fails closed, with
723
+ `tenant_context_unauthorized`). Consume the grant through
724
+ `withPrincipalPermissionContext()` / `assertOperationPermission()`, which read
725
+ the resolved permission set. Carrying `ancestorReadFromTenantIds` into
726
+ `tenantAuthorization` is tracked in #2947.
727
+
728
+ Bind a policy to one resolver instead of the global config (tests, embedded
729
+ runtimes) with `PermissionResolver.create(options, { ancestorReadPolicy })`;
730
+ pass `null` to force it off regardless of configuration.
731
+
732
+ #### Adoption note
733
+
734
+ This is additive and off by default — no migration is required, and no existing
735
+ deployment changes behavior until `permissions.ancestorRead` is declared. When
736
+ adopting it, declare the **narrowest** role and collection lists that make the
737
+ ancestor-level list work, and keep `maxDepth` at the smallest value your
738
+ hierarchy needs. `roles` must name system-role slugs; a tenant-scoped role with
739
+ the same slug is ignored by design. Do not reach for it to grant an ancestor-level action: if a
740
+ principal needs to *act* at the root, give it a root membership or a role
741
+ grant, not a read policy.
742
+
649
743
  ### Tenant policies
650
744
 
651
745
  TenantService supports three modes: `flexible` (no auto-create), `personal` (auto-create on first login, deletable), `required` (auto-create, must keep at least one).
@@ -683,7 +777,8 @@ TenantService supports three modes: `flexible` (no auto-create), `personal` (aut
683
777
 
684
778
  | Export | Description |
685
779
  |--------|-------------|
686
- | `PermissionResolver` | Resolves effective permissions via 4-level cascade. `hasPermission()`, `resolvePermissions()`. |
780
+ | `PermissionResolver` | Resolves effective permissions via 4-level cascade. `hasPermission()`, `resolvePermissions()`. Honors the opt-in `permissions.ancestorRead` policy. |
781
+ | `normalizeAncestorReadPolicy()`, `getConfiguredAncestorReadPolicy()`, `isAncestorReadableSlug()` | Validate and apply the declared read-only ancestor-visibility policy. |
687
782
  | `PermissionCatalogService`, `syncPermissionCatalog()` | Discovers manifest/config/runtime permissions and upserts them into `Permission` rows. |
688
783
  | `registerPermissionDefinitions()` | Register app or integration permissions at runtime and receive an unregister cleanup function. |
689
784
  | `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |