@xeplr/auth 1.0.0 → 1.0.2

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.
Files changed (59) hide show
  1. package/bin/migrate.js +58 -25
  2. package/bin/server.js +12 -30
  3. package/index.js +188 -48
  4. package/lib/adminRouter.js +20 -92
  5. package/lib/authHelper.js +26 -3
  6. package/lib/authMiddleware.js +39 -13
  7. package/lib/authRouter.js +144 -12
  8. package/lib/authService.js +448 -16
  9. package/lib/hooks.js +50 -0
  10. package/lib/mtMembershipMiddleware.js +61 -0
  11. package/lib/seed.js +74 -0
  12. package/lib/sessionService.js +54 -6
  13. package/lib/ticketService.js +88 -0
  14. package/lib/tokenDecision.js +45 -0
  15. package/migrations/0001_extensions.sql +9 -0
  16. package/migrations/0002_users.sql +31 -0
  17. package/migrations/0003_catalog_tables.sql +82 -0
  18. package/migrations/0004_role_mappings.sql +78 -0
  19. package/migrations/0005_user_tenants_mapping.sql +39 -0
  20. package/migrations/0006_seed_catalog.sql +120 -0
  21. package/models/Api.js +3 -0
  22. package/models/ApisRolesMapping.js +3 -0
  23. package/models/Menu.js +3 -0
  24. package/models/MenuRolesMapping.js +3 -0
  25. package/models/Role.js +8 -0
  26. package/models/UiElement.js +3 -0
  27. package/models/UiElementsRolesMapping.js +3 -0
  28. package/models/UiPage.js +3 -0
  29. package/models/UiPagesRolesMapping.js +3 -0
  30. package/models/User.js +3 -13
  31. package/models/UserRolesMapping.js +3 -0
  32. package/models/UserTenantsMapping.js +20 -2
  33. package/models/index.js +0 -2
  34. package/package.json +26 -5
  35. package/migrations/0001_users.js +0 -22
  36. package/migrations/0002_menus.js +0 -16
  37. package/migrations/0003_apis.js +0 -16
  38. package/migrations/0004_uiPages.js +0 -16
  39. package/migrations/0005_uiElements.js +0 -16
  40. package/migrations/0006_roles.js +0 -15
  41. package/migrations/0007_apisRolesMapping.js +0 -16
  42. package/migrations/0008_uiPagesRolesMapping.js +0 -16
  43. package/migrations/0009_uiElementsRolesMapping.js +0 -16
  44. package/migrations/0010_menuRolesMapping.js +0 -16
  45. package/migrations/0011_userRolesMapping.js +0 -16
  46. package/migrations/0012_users_add_reset_token.js +0 -13
  47. package/migrations/0013_add_isPublic.js +0 -27
  48. package/migrations/0014_users_add_activation_token.js +0 -13
  49. package/migrations/0015_add_mt_columns.js +0 -41
  50. package/migrations/0016_tenants.js +0 -23
  51. package/migrations/0017_userTenantsMapping.js +0 -20
  52. package/models/Tenant.js +0 -63
  53. package/seeds/001_roles.js +0 -26
  54. package/seeds/002_apis.js +0 -70
  55. package/seeds/003_pages.js +0 -41
  56. package/seeds/004_elements.js +0 -46
  57. package/seeds/005_menus.js +0 -35
  58. package/seeds/006_default_tenant.js +0 -50
  59. package/seeds/zzz_admin_access.js +0 -49
package/lib/seed.js ADDED
@@ -0,0 +1,74 @@
1
+ var { generateId, hashPassword } = require('./authHelper');
2
+
3
+ /**
4
+ * Ensure a Super Admin user exists and is mapped to the "Super Admin" role.
5
+ *
6
+ * Brand-neutral: the caller supplies WHO (email/password/name); this owns HOW
7
+ * (hashing, columns, role mapping). Insert-if-absent, so it is idempotent and
8
+ * safe to call from a migration on every run. Requires the base auth migrations
9
+ * (roles + users + userRolesMapping) to have run first.
10
+ *
11
+ * @param {import('knex').Knex} knex
12
+ * @param {{ email:string, password:string, name?:string }} admin
13
+ * @returns {Promise<{ userId:string, created:boolean, roleAssigned:boolean }>}
14
+ */
15
+ async function seedSuperAdmin(knex, admin) {
16
+ if (!admin || !admin.email || !admin.password) {
17
+ throw new Error('seedSuperAdmin: { email, password } are required');
18
+ }
19
+
20
+ var role = await knex('roles').where({ name: 'Super Admin' }).first();
21
+ if (!role) {
22
+ throw new Error('seedSuperAdmin: "Super Admin" role not found — run the base auth migrations first');
23
+ }
24
+
25
+ var now = new Date().toISOString();
26
+ var normalized = admin.email.toLowerCase();
27
+ var existing = await knex('users').where({ normalizedEmail: normalized }).first();
28
+
29
+ var userId;
30
+ var created = false;
31
+ if (existing) {
32
+ userId = existing.id;
33
+ } else {
34
+ userId = generateId();
35
+ var creds = await hashPassword(admin.password);
36
+ await knex('users').insert({
37
+ id: userId,
38
+ email: admin.email,
39
+ normalizedEmail: normalized,
40
+ name: admin.name || admin.email,
41
+ pwd: creds.hash,
42
+ pwdSalt: creds.salt,
43
+ isActive: true,
44
+ isActivated: true,
45
+ activatedOn: now,
46
+ activatedBy: userId,
47
+ recordCreatedDate: now,
48
+ recordModifiedDate: now,
49
+ recordCreatedBy: userId,
50
+ recordModifiedBy: userId
51
+ });
52
+ created = true;
53
+ }
54
+
55
+ var mapped = await knex('userRolesMapping').where({ userId: userId, roleId: role.id }).first();
56
+ var roleAssigned = false;
57
+ if (!mapped) {
58
+ await knex('userRolesMapping').insert({
59
+ id: generateId(),
60
+ userId: userId,
61
+ roleId: role.id,
62
+ isActive: true,
63
+ recordCreatedDate: now,
64
+ recordModifiedDate: now,
65
+ recordCreatedBy: userId,
66
+ recordModifiedBy: userId
67
+ });
68
+ roleAssigned = true;
69
+ }
70
+
71
+ return { userId: userId, created: created, roleAssigned: roleAssigned };
72
+ }
73
+
74
+ module.exports = { seedSuperAdmin };
@@ -17,24 +17,24 @@
17
17
  */
18
18
 
19
19
  const { cache } = require('@xeplr/utils');
20
- const { generateRefreshToken, generateAccessToken } = require('./authHelper');
20
+ const { generateRefreshToken, generateAccessToken, getToleranceSeconds } = require('./authHelper');
21
21
 
22
22
  const SESSION_PREFIX = 'session:';
23
23
  const ACCESS_PREFIX = 'access:';
24
24
  const USER_PREFIX = 'sessions:user:';
25
25
 
26
26
  function getRefreshTTL() {
27
- const days = parseInt(process.env.REFRESH_TOKEN_TTL_DAYS || '7');
27
+ const days = parseInt(process.env.AUTH_REFRESH_TOKEN_TTL_DAYS || '7');
28
28
  return days * 24 * 60 * 60;
29
29
  }
30
30
 
31
31
  function getAccessTTL() {
32
- const minutes = parseInt(process.env.ACCESS_TOKEN_TTL_MINUTES || '15');
32
+ const minutes = parseInt(process.env.AUTH_ACCESS_TOKEN_TTL_MINUTES || '15');
33
33
  return minutes * 60;
34
34
  }
35
35
 
36
36
  function getMaxSessions() {
37
- return parseInt(process.env.MAX_SESSIONS_PER_USER || '5');
37
+ return parseInt(process.env.AUTH_MAX_SESSIONS_PER_USER || '5');
38
38
  }
39
39
 
40
40
  /**
@@ -58,11 +58,13 @@ async function createSession(user) {
58
58
  createdAt: now
59
59
  }, refreshTTL);
60
60
 
61
- // Store access token whitelist entry (for middleware check)
61
+ // Store access token whitelist entry (for middleware check). The entry lives
62
+ // accessTTL + tolerance so it's still present during the grace window — that's
63
+ // what lets an expired-but-tolerated token find its live session and slide.
62
64
  await cache.set(ACCESS_PREFIX + accessToken, {
63
65
  userId: user.id,
64
66
  refreshToken
65
- }, accessTTL);
67
+ }, accessTTL + getToleranceSeconds());
66
68
 
67
69
  // Track session in user's sorted set
68
70
  const client = cache.getClient();
@@ -126,6 +128,51 @@ async function validateAccessToken(accessToken) {
126
128
  return await cache.get(ACCESS_PREFIX + accessToken);
127
129
  }
128
130
 
131
+ /**
132
+ * Slide an access token — the sliding refresh. Given an expired-but-tolerated
133
+ * access token (already signature-verified + session confirmed live by the
134
+ * middleware), mint a fresh access token for the SAME session and return it,
135
+ * so the middleware can hand it back on the response.
136
+ *
137
+ * Session-scoped IDEMPOTENT: if the session already slid to a newer token
138
+ * (a concurrent request beat us to it), hand that SAME token back instead of
139
+ * minting another — so 100 parallel requests yield ONE new token, not 100.
140
+ *
141
+ * The OLD token is deliberately left whitelisted (it expires on its own TTL),
142
+ * so other in-flight requests still carrying it keep passing during the window.
143
+ *
144
+ * NOTE: on a single node the check-then-set is race-free. Under HA/cluster,
145
+ * wrap the mint in a short Redis lock (or a Lua script) for atomicity.
146
+ *
147
+ * @param {string} oldToken the expired-but-tolerated access token
148
+ * @param {object} claims its verified JWT claims { id, email, name, roles }
149
+ * @returns {Promise<string|null>} the fresh access token, or null if no session
150
+ */
151
+ async function slideAccessToken(oldToken, claims) {
152
+ const wl = await cache.get(ACCESS_PREFIX + oldToken);
153
+ if (!wl || !wl.refreshToken) return null;
154
+
155
+ const session = await cache.get(SESSION_PREFIX + wl.refreshToken);
156
+ if (!session) return null;
157
+
158
+ // Already slid by a concurrent request → return the current token (idempotent).
159
+ if (session.accessToken && session.accessToken !== oldToken) {
160
+ return session.accessToken;
161
+ }
162
+
163
+ // Mint from the token's own claims — no DB hit. (Roles refresh fully on the
164
+ // next /refresh or re-login; sliding just extends the active session.)
165
+ const newToken = generateAccessToken({
166
+ id: claims.id, email: claims.email, name: claims.name, roles: claims.roles || []
167
+ });
168
+ const whitelistTTL = getAccessTTL() + getToleranceSeconds();
169
+
170
+ await cache.set(ACCESS_PREFIX + newToken, { userId: wl.userId, refreshToken: wl.refreshToken }, whitelistTTL);
171
+ await cache.set(SESSION_PREFIX + wl.refreshToken, Object.assign({}, session, { accessToken: newToken }), getRefreshTTL());
172
+
173
+ return newToken;
174
+ }
175
+
129
176
  /**
130
177
  * Rotate a session — validate old refresh token, create new pair.
131
178
  * Returns { accessToken, refreshToken, userId } or null.
@@ -204,6 +251,7 @@ module.exports = {
204
251
  createSession,
205
252
  getSession,
206
253
  validateAccessToken,
254
+ slideAccessToken,
207
255
  rotateSession,
208
256
  destroySession,
209
257
  destroyAllUserSessions
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ticketService — one-time-use tickets for out-of-band auth channels
3
+ * (SSE, WebSocket) where the browser can't attach a normal Authorization
4
+ * header.
5
+ *
6
+ * FLOW
7
+ * 1. Client makes an AUTHENTICATED POST to /auth/api/sse-ticket
8
+ * 2. issueTicket({ userId, scopes }) mints a random 24-byte token,
9
+ * stores it in Redis with a 30s TTL, returns { ticket, expiresIn }
10
+ * 3. Client opens EventSource('/events?ticket=<tk>&topics=...')
11
+ * 4. SSE handler calls consumeTicket(tk) which atomically GETDEL's
12
+ * the token. Returns { userId, scopes } on success, null on
13
+ * missing / expired / already-consumed.
14
+ * 5. SSE handler enforces the requested topics are within `scopes`.
15
+ *
16
+ * SECURITY PROPERTIES
17
+ * · Single-use — Redis GETDEL is atomic; two concurrent connects
18
+ * with the same URL cannot both succeed
19
+ * · Short-lived — default 30s; only needs to survive the roundtrip
20
+ * from /sse-ticket → EventSource open
21
+ * · Bearer-free — no long-lived token ever appears in a query string
22
+ * · Scope-bound — the ticket carries the exact set of subscribe
23
+ * scopes the user was authorized for at mint time;
24
+ * the SSE handler enforces this per-topic
25
+ *
26
+ * STORAGE
27
+ * @xeplr/utils cache is Redis-backed via ioredis. We use client.getdel()
28
+ * directly (bypassing cache.set's error swallowing) so a Redis outage
29
+ * surfaces immediately at ticket mint time — the caller learns "auth
30
+ * backend unavailable" instead of "your ticket silently didn't stick
31
+ * and the connection is now inexplicably 401".
32
+ */
33
+
34
+ const crypto = require('crypto');
35
+ const { cache } = require('@xeplr/utils');
36
+
37
+ const TICKET_PREFIX = 'ticket:sse:';
38
+ const DEFAULT_TTL = 30; // seconds
39
+
40
+ function newTicketId() {
41
+ return crypto.randomBytes(24).toString('base64url');
42
+ }
43
+
44
+ /**
45
+ * Issue a one-time ticket bound to a user + scope set.
46
+ *
47
+ * @param {object} payload — { userId, scopes: string[], ... }
48
+ * @param {object} [opts]
49
+ * @param {number} [opts.ttl] — seconds (default 30)
50
+ * @returns {Promise<{ ticket: string, expiresIn: number }>}
51
+ * @throws if Redis is unreachable (fail loud — this
52
+ * is an auth surface, silent failure is a bug)
53
+ */
54
+ async function issueTicket(payload, opts) {
55
+ if (!payload || !payload.userId) {
56
+ throw new Error('ticketService.issueTicket: payload.userId is required');
57
+ }
58
+ var ttl = (opts && opts.ttl) || DEFAULT_TTL;
59
+ var ticket = newTicketId();
60
+
61
+ var client = cache.getClient();
62
+ await client.set(TICKET_PREFIX + ticket, JSON.stringify(payload), 'EX', ttl);
63
+ return { ticket: ticket, expiresIn: ttl };
64
+ }
65
+
66
+ /**
67
+ * Atomically consume a ticket. Returns the payload on success, null if
68
+ * the ticket doesn't exist / expired / was already consumed.
69
+ *
70
+ * Guarantees single-use even under concurrent lookups (Redis GETDEL is
71
+ * a single atomic op).
72
+ *
73
+ * @param {string} ticket
74
+ * @returns {Promise<object|null>}
75
+ */
76
+ async function consumeTicket(ticket) {
77
+ if (!ticket || typeof ticket !== 'string') return null;
78
+ var client = cache.getClient();
79
+ var raw = await client.getdel(TICKET_PREFIX + ticket);
80
+ if (!raw) return null;
81
+ try { return JSON.parse(raw); }
82
+ catch (_) { return null; }
83
+ }
84
+
85
+ module.exports = {
86
+ issueTicket: issueTicket,
87
+ consumeTicket: consumeTicket
88
+ };
@@ -0,0 +1,45 @@
1
+ // Pure token decision — NO I/O, NO side effects. This is the heart of the
2
+ // tolerance-refresh scheme, deliberately isolated so its edges are covered by
3
+ // tests instead of discovered in production. The middleware does the I/O
4
+ // (verify signature, look up the session) and hands the facts to decide().
5
+ //
6
+ // decide({ exp, now, toleranceSeconds, sessionLive, signatureValid })
7
+ // → { action, reason }
8
+ //
9
+ // action:
10
+ // 'ok' → token is valid; serve normally
11
+ // 'slide' → token expired but within tolerance; serve AND issue a fresh
12
+ // token (attach it to the response) — the sliding refresh
13
+ // 'reject' → do not serve (401)
14
+ //
15
+ // inputs (all plain values — that's the point):
16
+ // exp token expiry, UNIX seconds (JWT `exp`)
17
+ // now current time, UNIX seconds
18
+ // toleranceSeconds grace past expiry during which an expired token is still
19
+ // honored + refreshed. 0 = strict (reject on expiry).
20
+ // sessionLive is the session still valid server-side (NOT logged
21
+ // out / revoked)? A revoke is NEVER graced.
22
+ // signatureValid did the JWT signature verify?
23
+ //
24
+ // Order matters: signature → revocation → expiry. A bad signature or a revoked
25
+ // session is rejected regardless of the clock; only a validly-signed, live
26
+ // session gets the tolerance grace.
27
+
28
+ function decide(input) {
29
+ input = input || {};
30
+ var exp = input.exp;
31
+ var now = input.now;
32
+ var tolerance = input.toleranceSeconds || 0;
33
+
34
+ if (!input.signatureValid) return { action: 'reject', reason: 'bad-signature' };
35
+ if (!input.sessionLive) return { action: 'reject', reason: 'revoked' }; // never grace a revoke
36
+ if (typeof exp !== 'number' || typeof now !== 'number') {
37
+ return { action: 'reject', reason: 'no-expiry' };
38
+ }
39
+
40
+ if (now <= exp) return { action: 'ok', reason: 'valid' };
41
+ if (now <= exp + tolerance) return { action: 'slide', reason: 'within-tolerance' };
42
+ return { action: 'reject', reason: 'expired' };
43
+ }
44
+
45
+ module.exports = { decide };
@@ -0,0 +1,9 @@
1
+ -- 0001_extensions.sql
2
+ -- pgcrypto gives us gen_random_bytes() for id generation (see 0006's
3
+ -- encode(gen_random_bytes(12),'hex') — matches the app's own generateId()
4
+ -- shape) and crypt()/gen_salt('bf') — real bcrypt-compatible hashes, usable
5
+ -- from plain SQL for any app-level super-admin bootstrap migration (see
6
+ -- lib/seed.js's seedSuperAdmin, or roll your own like this). Node's `bcrypt`
7
+ -- package can compare() against these hashes directly (same algorithm family).
8
+
9
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
@@ -0,0 +1,31 @@
1
+ -- 0002_users.sql
2
+
3
+ CREATE TABLE "users" (
4
+ "id" varchar(25) PRIMARY KEY,
5
+ "email" varchar(255) NOT NULL,
6
+ "normalizedEmail" varchar(255),
7
+ "phoneNumber" varchar(255),
8
+ "phoneVerified" boolean DEFAULT false,
9
+ "phoneVerifiedOn" timestamp,
10
+ "name" varchar(255),
11
+ "pwd" varchar(255),
12
+ "pwdSalt" varchar(100),
13
+ "profilePicUrl" varchar(500),
14
+ "isActive" boolean DEFAULT false,
15
+ "isActivated" boolean DEFAULT false,
16
+ "activatedOn" timestamp,
17
+ "activatedBy" varchar(25),
18
+ "resetToken" varchar(255),
19
+ "resetTokenExpiry" timestamp,
20
+ "activationToken" varchar(255),
21
+ "mtId1" varchar(25),
22
+ "mtId2" varchar(25),
23
+ "mtId3" varchar(25),
24
+ "mtId4" varchar(25),
25
+ "recordCreatedDate" timestamp,
26
+ "recordModifiedDate" timestamp,
27
+ "recordCreatedBy" varchar(25),
28
+ "recordModifiedBy" varchar(25)
29
+ );
30
+
31
+ CREATE INDEX "users_normalizedEmail_index" ON "users" ("normalizedEmail");
@@ -0,0 +1,82 @@
1
+ -- 0003_catalog_tables.sql
2
+ -- roles + the four access-catalog tables (apis/uiPages/uiElements/menus),
3
+ -- each independently role-mappable and each with an isPublic escape hatch
4
+ -- (accessService.getPublicItems() unions isPublic:true rows into every
5
+ -- authenticated user's access regardless of role).
6
+
7
+ CREATE TABLE "roles" (
8
+ "id" varchar(25) PRIMARY KEY,
9
+ "name" varchar(255),
10
+ "isActive" boolean DEFAULT false,
11
+ "mtId1" varchar(25),
12
+ "mtId2" varchar(25),
13
+ "mtId3" varchar(25),
14
+ "mtId4" varchar(25),
15
+ "recordCreatedDate" timestamp,
16
+ "recordModifiedDate" timestamp,
17
+ "recordCreatedBy" varchar(25),
18
+ "recordModifiedBy" varchar(25)
19
+ );
20
+
21
+ CREATE TABLE "apis" (
22
+ "id" varchar(25) PRIMARY KEY,
23
+ "name" varchar(255),
24
+ "apiGroup" varchar(255),
25
+ "isPublic" boolean DEFAULT false,
26
+ "isActive" boolean DEFAULT false,
27
+ "mtId1" varchar(25),
28
+ "mtId2" varchar(25),
29
+ "mtId3" varchar(25),
30
+ "mtId4" varchar(25),
31
+ "recordCreatedDate" timestamp,
32
+ "recordModifiedDate" timestamp,
33
+ "recordCreatedBy" varchar(25),
34
+ "recordModifiedBy" varchar(25)
35
+ );
36
+
37
+ CREATE TABLE "uiPages" (
38
+ "id" varchar(25) PRIMARY KEY,
39
+ "name" varchar(255),
40
+ "uiPagesGroup" varchar(255),
41
+ "isPublic" boolean DEFAULT false,
42
+ "isActive" boolean DEFAULT false,
43
+ "mtId1" varchar(25),
44
+ "mtId2" varchar(25),
45
+ "mtId3" varchar(25),
46
+ "mtId4" varchar(25),
47
+ "recordCreatedDate" timestamp,
48
+ "recordModifiedDate" timestamp,
49
+ "recordCreatedBy" varchar(25),
50
+ "recordModifiedBy" varchar(25)
51
+ );
52
+
53
+ CREATE TABLE "uiElements" (
54
+ "id" varchar(25) PRIMARY KEY,
55
+ "name" varchar(255),
56
+ "uiElementsGroup" varchar(255),
57
+ "isActive" boolean DEFAULT false,
58
+ "mtId1" varchar(25),
59
+ "mtId2" varchar(25),
60
+ "mtId3" varchar(25),
61
+ "mtId4" varchar(25),
62
+ "recordCreatedDate" timestamp,
63
+ "recordModifiedDate" timestamp,
64
+ "recordCreatedBy" varchar(25),
65
+ "recordModifiedBy" varchar(25)
66
+ );
67
+
68
+ CREATE TABLE "menus" (
69
+ "id" varchar(25) PRIMARY KEY,
70
+ "name" varchar(255),
71
+ "menuGroup" varchar(255),
72
+ "isPublic" boolean DEFAULT false,
73
+ "isActive" boolean DEFAULT false,
74
+ "mtId1" varchar(25),
75
+ "mtId2" varchar(25),
76
+ "mtId3" varchar(25),
77
+ "mtId4" varchar(25),
78
+ "recordCreatedDate" timestamp,
79
+ "recordModifiedDate" timestamp,
80
+ "recordCreatedBy" varchar(25),
81
+ "recordModifiedBy" varchar(25)
82
+ );
@@ -0,0 +1,78 @@
1
+ -- 0004_role_mappings.sql
2
+ -- Join tables: which roles can see which api/page/element/menu, and which
3
+ -- users hold which roles.
4
+
5
+ CREATE TABLE "apisRolesMapping" (
6
+ "id" varchar(25) PRIMARY KEY,
7
+ "roleId" varchar(25) REFERENCES "roles"("id"),
8
+ "apiId" varchar(25) REFERENCES "apis"("id"),
9
+ "isActive" boolean DEFAULT false,
10
+ "mtId1" varchar(25),
11
+ "mtId2" varchar(25),
12
+ "mtId3" varchar(25),
13
+ "mtId4" varchar(25),
14
+ "recordCreatedDate" timestamp,
15
+ "recordModifiedDate" timestamp,
16
+ "recordCreatedBy" varchar(25),
17
+ "recordModifiedBy" varchar(25)
18
+ );
19
+
20
+ CREATE TABLE "uiPagesRolesMapping" (
21
+ "id" varchar(25) PRIMARY KEY,
22
+ "roleId" varchar(25) REFERENCES "roles"("id"),
23
+ "uiPageId" varchar(25) REFERENCES "uiPages"("id"),
24
+ "isActive" boolean DEFAULT false,
25
+ "mtId1" varchar(25),
26
+ "mtId2" varchar(25),
27
+ "mtId3" varchar(25),
28
+ "mtId4" varchar(25),
29
+ "recordCreatedDate" timestamp,
30
+ "recordModifiedDate" timestamp,
31
+ "recordCreatedBy" varchar(25),
32
+ "recordModifiedBy" varchar(25)
33
+ );
34
+
35
+ CREATE TABLE "uiElementsRolesMapping" (
36
+ "id" varchar(25) PRIMARY KEY,
37
+ "roleId" varchar(25) REFERENCES "roles"("id"),
38
+ "uiElementId" varchar(25) REFERENCES "uiElements"("id"),
39
+ "isActive" boolean DEFAULT false,
40
+ "mtId1" varchar(25),
41
+ "mtId2" varchar(25),
42
+ "mtId3" varchar(25),
43
+ "mtId4" varchar(25),
44
+ "recordCreatedDate" timestamp,
45
+ "recordModifiedDate" timestamp,
46
+ "recordCreatedBy" varchar(25),
47
+ "recordModifiedBy" varchar(25)
48
+ );
49
+
50
+ CREATE TABLE "menuRolesMapping" (
51
+ "id" varchar(25) PRIMARY KEY,
52
+ "menuId" varchar(25) REFERENCES "menus"("id"),
53
+ "roleId" varchar(25) REFERENCES "roles"("id"),
54
+ "isActive" boolean DEFAULT false,
55
+ "mtId1" varchar(25),
56
+ "mtId2" varchar(25),
57
+ "mtId3" varchar(25),
58
+ "mtId4" varchar(25),
59
+ "recordCreatedDate" timestamp,
60
+ "recordModifiedDate" timestamp,
61
+ "recordCreatedBy" varchar(25),
62
+ "recordModifiedBy" varchar(25)
63
+ );
64
+
65
+ CREATE TABLE "userRolesMapping" (
66
+ "id" varchar(25) PRIMARY KEY,
67
+ "userId" varchar(25) REFERENCES "users"("id"),
68
+ "roleId" varchar(25) REFERENCES "roles"("id"),
69
+ "isActive" boolean DEFAULT false,
70
+ "mtId1" varchar(25),
71
+ "mtId2" varchar(25),
72
+ "mtId3" varchar(25),
73
+ "mtId4" varchar(25),
74
+ "recordCreatedDate" timestamp,
75
+ "recordModifiedDate" timestamp,
76
+ "recordCreatedBy" varchar(25),
77
+ "recordModifiedBy" varchar(25)
78
+ );
@@ -0,0 +1,39 @@
1
+ -- 0005_user_tenants_mapping.sql
2
+ -- Row-per-level membership registry. `level` is the generic MT slot (l1-l4,
3
+ -- matching mtId1-4) — not the app-defined name (companyId/workspaceId/...);
4
+ -- that name<->slot mapping lives only in the app's own registerMTs() config
5
+ -- (see @xeplr/db's BaseModel.registerMTs). `value` is the app's own id at
6
+ -- that level — auth doesn't own or validate a tenant tree, it just records
7
+ -- "this user is a member at this level with this value".
8
+ --
9
+ -- This table is deliberately NOT multiTenant-filtered by BaseModel (see
10
+ -- UserTenantsMapping.js's `multiTenant: false`) — it must be queryable before
11
+ -- any active scope exists (chicken-and-egg: you need this table to find out
12
+ -- which company to activate in the first place).
13
+
14
+ CREATE TABLE "userTenantsMapping" (
15
+ "id" varchar(25) PRIMARY KEY,
16
+ "userId" varchar(25) NOT NULL REFERENCES "users"("id"),
17
+ "level" varchar(2) NOT NULL CHECK ("level" IN ('l1', 'l2', 'l3', 'l4')),
18
+ "value" varchar(255) NOT NULL,
19
+ "roleId" varchar(25) REFERENCES "roles"("id"),
20
+ "isActive" boolean DEFAULT true,
21
+ "mtId1" varchar(25),
22
+ "mtId2" varchar(25),
23
+ "mtId3" varchar(25),
24
+ "mtId4" varchar(25),
25
+ "recordCreatedDate" timestamp,
26
+ "recordModifiedDate" timestamp,
27
+ "recordCreatedBy" varchar(25),
28
+ "recordModifiedBy" varchar(25),
29
+ UNIQUE ("userId", "level", "value")
30
+ );
31
+
32
+ -- Enforcement lookup (mtMembershipMiddleware): "does this user have a grant
33
+ -- at level X with value Y?"
34
+ CREATE INDEX "userTenantsMapping_userId_level_value_index"
35
+ ON "userTenantsMapping" ("userId", "level", "value");
36
+
37
+ -- "Who has access to this company/workspace?" lookups.
38
+ CREATE INDEX "userTenantsMapping_level_value_index"
39
+ ON "userTenantsMapping" ("level", "value");