@happyvertical/smrt-users 0.31.1 → 0.32.1

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
@@ -18,14 +18,30 @@ Multi-tenant user management with RBAC, hierarchical tenants, session handling,
18
18
  | MembershipOverride | Per-user permission grant/deny. **DENY always wins.** |
19
19
  | TenantPermissionOverride | Tenant-level cascade overrides. Effect: INHERIT/GRANT/DENY. |
20
20
 
21
- ## Permission Resolution — 4-Level Cascade
22
-
23
- PermissionResolver evaluates in order (each level can add/remove permissions):
24
-
25
- 1. **Tenant hierarchy** — walk ancestors, apply TenantPermissionOverride at each level
26
- 2. **Membership role** — base permissions from user's role in tenant
27
- 3. **Group roles** permissions from all groups user belongs to **in that tenant**
28
- 4. **Membership overrides** — final GRANT/DENY per-user (DENY takes absolute precedence)
21
+ ## Permission Resolution — Precedence (broad → specific, most-specific wins)
22
+
23
+ `PermissionResolver.resolvePermissions` builds the effective set in this order;
24
+ each later layer overrides earlier ones:
25
+
26
+ 1. **Tenant-inherited** — walk ancestors, apply each `TenantPermissionOverride`
27
+ down the cascade (GRANT adds, DENY removes within the hierarchy)
28
+ 2. **Membership role** — base permissions from the user's role in the tenant
29
+ 3. **Group roles** — permissions from all groups the user belongs to **in that tenant**
30
+ 4. **Tenant-level DENY** *(removes; overrides role/group grants, tenant-wide)* — a
31
+ `TenantPermissionOverride` with effect `DENY` is a HARD, tenant-wide block: it
32
+ subtracts the DENY'd slug even if a role or group granted it (steps 2–3). It
33
+ sits just **above** the per-user membership overrides and **below** role/group.
34
+ 5. **Membership GRANT override** *(re-adds; most specific)* — a per-user GRANT can
35
+ re-add a slug a tenant DENY'd in step 4, because it is more specific.
36
+ 6. **Membership DENY override** *(absolute; always wins)* — a per-user DENY removes
37
+ the slug last and is never overridden.
38
+
39
+ So a permission a role grants but the tenant DENYs is **removed**, unless that
40
+ exact user also has a membership-GRANT override for it. A membership-DENY always
41
+ wins. Tenant-DENY of an inherited/cascade grant still blocks it (unchanged).
42
+ The hard block reflects the tenant cascade's **net** resolution, not an
43
+ unconditional union of every DENY in the chain — so a more-specific tenant GRANT
44
+ (e.g. a child sub-tenant re-granting a permission its parent DENYs) still wins.
29
45
 
30
46
  **Critical**: `getGroupIdsForTenant(userId, tenantId)` (joins with groups table to scope by tenant). Never use `getGroupIds()` — it's cross-tenant.
31
47
 
@@ -64,13 +80,22 @@ await switchSessionTenant(event, tenantId, { db });
64
80
  structural regression test (`security-audit-1400.test.ts`) enumerates the
65
81
  registry to assert no authority model exposes a mutating op. (`cli` stays
66
82
  enabled — local-operator surface, outside the network/agent threat model.)
67
- - **`switchTenant` is fail-closed.** `SessionService.switchTenant` /
68
- `switchSessionTenant` verify the session's user has an ACTIVE membership in the
69
- target tenant before writing `session.tenantId` (the tenant-isolation key for
70
- every `@TenantScoped` query). A non-member switch returns `false` without
71
- mutating the session; `null` clears the context and is always allowed. The
72
- low-level `SessionCollection.setSessionTenant` is the UNGUARDED primitive
73
- never call it with an untrusted tenant id.
83
+ - **`switchTenant` is fail-closed AND rotates the session id.**
84
+ `SessionService.switchTenant` / `switchSessionTenant` verify the session's user
85
+ has an ACTIVE membership in the target tenant before any write (the tenant id
86
+ is the isolation key for every `@TenantScoped` query). A non-member/unknown-
87
+ session switch returns `{ switched: false, sessionId: null, ... }` and mutates
88
+ nothing. On a successful switch into a NON-null tenant the session id is
89
+ ROTATED: a fresh `Session` (new secure id, fresh TTL, same user, new tenant,
90
+ device context carried over) is minted and the old session is REVOKED — so a
91
+ captured pre-switch id immediately stops validating, shrinking the blast radius
92
+ of a leaked id across a tenant boundary. `switchTenant` returns a
93
+ `SwitchTenantResult` (`{ switched, sessionId, session, rotated }`); callers MUST
94
+ persist the returned `sessionId`. `switchSessionTenant` does this for you by
95
+ re-setting the session cookie (preserving httpOnly/secure/sameSite) to the new
96
+ id. A `null` clear stays in place (no rotation, no cookie change). The
97
+ low-level `SessionCollection.setSessionTenant` is the UNGUARDED primitive (used
98
+ for the null-clear path) — never call it with an untrusted tenant id.
74
99
  - **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`
75
100
  refuses to provision a user when the IdP explicitly returns
76
101
  `email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An
@@ -4295,7 +4295,8 @@ class PermissionResolver {
4295
4295
  const result = {
4296
4296
  permissions: /* @__PURE__ */ new Set(),
4297
4297
  contributingTenantIds: [],
4298
- inheritanceActive: false
4298
+ inheritanceActive: false,
4299
+ deniedPermissions: /* @__PURE__ */ new Set()
4299
4300
  };
4300
4301
  const tenant = await this.tenantCollection.get({ id: tenantId });
4301
4302
  if (!tenant) {
@@ -4308,9 +4309,13 @@ class PermissionResolver {
4308
4309
  chainTenantIds
4309
4310
  );
4310
4311
  const allPermissionIds = /* @__PURE__ */ new Set();
4312
+ const deniedPermissionIds = /* @__PURE__ */ new Set();
4311
4313
  for (const overrides of allOverridesMap.values()) {
4312
4314
  for (const id of overrides.grantedPermissionIds) allPermissionIds.add(id);
4313
- for (const id of overrides.deniedPermissionIds) allPermissionIds.add(id);
4315
+ for (const id of overrides.deniedPermissionIds) {
4316
+ allPermissionIds.add(id);
4317
+ deniedPermissionIds.add(id);
4318
+ }
4314
4319
  }
4315
4320
  let inheritedPermissions = /* @__PURE__ */ new Set();
4316
4321
  for (let i = 0; i < chain.length; i++) {
@@ -4358,6 +4363,13 @@ class PermissionResolver {
4358
4363
  result.permissions.add(perm.slug);
4359
4364
  }
4360
4365
  }
4366
+ for (const permId of deniedPermissionIds) {
4367
+ if (inheritedPermissions.has(permId)) continue;
4368
+ const perm = permissionsMap.get(permId);
4369
+ if (perm?.slug) {
4370
+ result.deniedPermissions.add(perm.slug);
4371
+ }
4372
+ }
4361
4373
  }
4362
4374
  return result;
4363
4375
  }
@@ -4389,13 +4401,23 @@ class PermissionResolver {
4389
4401
  return chain;
4390
4402
  }
4391
4403
  /**
4392
- * Resolve all effective permissions for a user in a tenant
4404
+ * Resolve all effective permissions for a user in a tenant.
4405
+ *
4406
+ * Precedence (broad -> specific, most-specific wins):
4407
+ * tenant-inherited (cascade)
4408
+ * -> role
4409
+ * -> group roles
4410
+ * -> tenant-DENY (removes; overrides role/group grants, tenant-wide)
4411
+ * -> membership GRANT (re-adds; most specific, can win over a tenant-DENY)
4412
+ * -> membership DENY (absolute; always wins)
4393
4413
  *
4394
4414
  * Algorithm:
4395
4415
  * 1. Get membership and collect all permission IDs from all sources
4396
4416
  * 2. Batch fetch all permissions in a single query
4397
- * 3. Apply permissions from role, groups, and overrides
4398
- * 4. DENY overrides take precedence over GRANT
4417
+ * 3. Apply permissions from role, then groups
4418
+ * 4. Subtract tenant-level DENY'd slugs (hard tenant-wide block)
4419
+ * 5. Apply membership GRANT overrides (can re-add a tenant-DENY'd slug)
4420
+ * 6. Subtract membership DENY overrides (absolute precedence)
4399
4421
  */
4400
4422
  async resolvePermissions(userId, tenantId, options = {}) {
4401
4423
  const result = {
@@ -4484,6 +4506,9 @@ class PermissionResolver {
4484
4506
  }
4485
4507
  }
4486
4508
  }
4509
+ for (const slug of tenantPermissions.deniedPermissions) {
4510
+ result.permissions.delete(slug);
4511
+ }
4487
4512
  for (const permId of grantedPermissionIds) {
4488
4513
  const slug = permissionIdToSlug.get(permId);
4489
4514
  if (slug) {
@@ -4643,23 +4668,66 @@ class SessionService {
4643
4668
  * query, so it must never be set to a tenant the session's user is not an
4644
4669
  * active member of — otherwise a caller could read/write another tenant's data
4645
4670
  * by feeding an arbitrary id here (e.g. straight from untrusted form data).
4646
- * Fail-closed (#1400): returns `false` without switching when the session is
4647
- * unknown or the user has no active membership in the target tenant. Passing
4648
- * `null` clears the tenant context and is always allowed.
4671
+ *
4672
+ * Fail-closed (#1400): the user's ACTIVE membership in the target tenant is
4673
+ * verified BEFORE any write. A non-member switch returns
4674
+ * `{ switched: false, ... }` and mutates nothing.
4675
+ *
4676
+ * Session-id ROTATION (#1354 follow-up): a successful switch into a non-null
4677
+ * tenant mints a BRAND-NEW session (fresh secure id, fresh TTL) for the same
4678
+ * user with the new tenant, then REVOKES the old session — so any captured
4679
+ * pre-switch session id immediately stops validating, shrinking the blast
4680
+ * radius of a leaked id across a privilege/tenant boundary. The device context
4681
+ * (user agent, IP, custom data) carries over to the new session. Callers MUST
4682
+ * persist the returned `sessionId` (e.g. re-set the cookie).
4683
+ *
4684
+ * Passing `null` clears the tenant context, is always allowed, and stays
4685
+ * in-place (no rotation — there is no privilege boundary being crossed).
4686
+ *
4687
+ * @returns A {@link SwitchTenantResult}; check `switched` for success.
4649
4688
  */
4650
4689
  async switchTenant(sessionId, tenantId) {
4690
+ const failClosed = {
4691
+ switched: false,
4692
+ sessionId: null,
4693
+ session: null,
4694
+ rotated: false
4695
+ };
4651
4696
  const session = await this.sessionCollection.findValidSession(sessionId);
4652
- if (!session) return false;
4653
- if (tenantId !== null) {
4654
- const membership = await this.membershipCollection.findByUserAndTenant(
4655
- session.userId,
4656
- tenantId
4657
- );
4658
- if (!membership || !membership.isActive()) {
4659
- return false;
4660
- }
4697
+ if (!session) return failClosed;
4698
+ if (tenantId === null) {
4699
+ const ok = await this.sessionCollection.setSessionTenant(sessionId, null);
4700
+ if (!ok) return failClosed;
4701
+ const updated = await this.sessionCollection.findValidSession(sessionId);
4702
+ return {
4703
+ switched: true,
4704
+ sessionId,
4705
+ session: updated,
4706
+ rotated: false
4707
+ };
4661
4708
  }
4662
- return this.sessionCollection.setSessionTenant(sessionId, tenantId);
4709
+ const membership = await this.membershipCollection.findByUserAndTenant(
4710
+ session.userId,
4711
+ tenantId
4712
+ );
4713
+ if (!membership || !membership.isActive()) {
4714
+ return failClosed;
4715
+ }
4716
+ await this.sessionCollection.revokeSession(sessionId);
4717
+ const rotated = await this.sessionCollection.createSession({
4718
+ userId: session.userId,
4719
+ tenantId,
4720
+ ttl: this.defaultTTL,
4721
+ userAgent: session.userAgent,
4722
+ ipAddress: session.ipAddress,
4723
+ data: session.data
4724
+ });
4725
+ return {
4726
+ switched: true,
4727
+ sessionId: rotated.id,
4728
+ session: rotated,
4729
+ rotated: true
4730
+ };
4663
4731
  }
4664
4732
  /**
4665
4733
  * Get all active sessions for a user (for "manage sessions" UI)
@@ -5205,4 +5273,4 @@ export {
5205
5273
  SessionCollection as y,
5206
5274
  SessionService as z
5207
5275
  };
5208
- //# sourceMappingURL=TerminalAuthService-DsQBk1Hc.js.map
5276
+ //# sourceMappingURL=TerminalAuthService-D5VVPG9e.js.map