hazo_auth 9.1.1 → 10.1.0

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 (81) hide show
  1. package/README.md +124 -6
  2. package/SETUP_CHECKLIST.md +24 -16
  3. package/cli-src/cli/init_users.ts +40 -48
  4. package/cli-src/lib/auth/auth_types.ts +0 -2
  5. package/cli-src/lib/auth/hazo_get_auth.server.ts +31 -25
  6. package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +9 -13
  7. package/cli-src/lib/auth/nextauth_config.ts +41 -0
  8. package/cli-src/lib/auth/request_google_scopes.ts +23 -0
  9. package/cli-src/lib/constants.ts +2 -0
  10. package/cli-src/lib/profile_pic_menu_config.server.ts +4 -3
  11. package/cli-src/lib/schema/sqlite_schema.ts +16 -4
  12. package/cli-src/lib/scope_hierarchy_config.server.ts +1 -9
  13. package/cli-src/lib/services/google_token_service.ts +408 -0
  14. package/cli-src/lib/services/index.ts +1 -1
  15. package/cli-src/lib/services/invitation_service.ts +1 -1
  16. package/cli-src/lib/services/scope_service.ts +2 -76
  17. package/cli-src/lib/services/user_scope_service.ts +7 -61
  18. package/dist/cli/init_users.d.ts.map +1 -1
  19. package/dist/cli/init_users.js +42 -42
  20. package/dist/client.d.ts +2 -1
  21. package/dist/client.d.ts.map +1 -1
  22. package/dist/client.js +3 -1
  23. package/dist/components/layouts/google_token_test/index.d.ts +6 -0
  24. package/dist/components/layouts/google_token_test/index.d.ts.map +1 -0
  25. package/dist/components/layouts/google_token_test/index.js +74 -0
  26. package/dist/components/layouts/shared/components/profile_pic_menu.d.ts.map +1 -1
  27. package/dist/components/layouts/shared/components/profile_pic_menu.js +7 -1
  28. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  29. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/lib/auth/auth_types.d.ts +0 -2
  34. package/dist/lib/auth/auth_types.d.ts.map +1 -1
  35. package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
  36. package/dist/lib/auth/hazo_get_auth.server.js +27 -19
  37. package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
  38. package/dist/lib/auth/hazo_get_tenant_auth.server.js +10 -10
  39. package/dist/lib/auth/nextauth_config.d.ts +2 -0
  40. package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
  41. package/dist/lib/auth/nextauth_config.js +39 -1
  42. package/dist/lib/auth/request_google_scopes.d.ts +10 -0
  43. package/dist/lib/auth/request_google_scopes.d.ts.map +1 -0
  44. package/dist/lib/auth/request_google_scopes.js +13 -0
  45. package/dist/lib/constants.d.ts +1 -0
  46. package/dist/lib/constants.d.ts.map +1 -1
  47. package/dist/lib/constants.js +1 -0
  48. package/dist/lib/profile_pic_menu_config.server.d.ts +2 -1
  49. package/dist/lib/profile_pic_menu_config.server.d.ts.map +1 -1
  50. package/dist/lib/profile_pic_menu_config.server.js +1 -1
  51. package/dist/lib/schema/sqlite_schema.d.ts +1 -1
  52. package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
  53. package/dist/lib/schema/sqlite_schema.js +16 -4
  54. package/dist/lib/scope_hierarchy_config.server.d.ts +0 -2
  55. package/dist/lib/scope_hierarchy_config.server.d.ts.map +1 -1
  56. package/dist/lib/scope_hierarchy_config.server.js +1 -3
  57. package/dist/lib/services/google_token_service.d.ts +48 -0
  58. package/dist/lib/services/google_token_service.d.ts.map +1 -0
  59. package/dist/lib/services/google_token_service.js +319 -0
  60. package/dist/lib/services/index.d.ts +1 -0
  61. package/dist/lib/services/index.d.ts.map +1 -1
  62. package/dist/lib/services/index.js +1 -0
  63. package/dist/lib/services/invitation_service.d.ts +1 -1
  64. package/dist/lib/services/invitation_service.js +1 -1
  65. package/dist/lib/services/scope_service.d.ts +1 -14
  66. package/dist/lib/services/scope_service.d.ts.map +1 -1
  67. package/dist/lib/services/scope_service.js +2 -67
  68. package/dist/lib/services/user_scope_service.d.ts +5 -12
  69. package/dist/lib/services/user_scope_service.d.ts.map +1 -1
  70. package/dist/lib/services/user_scope_service.js +8 -45
  71. package/dist/server/routes/google_token.d.ts +13 -0
  72. package/dist/server/routes/google_token.d.ts.map +1 -0
  73. package/dist/server/routes/google_token.js +66 -0
  74. package/dist/server/routes/index.d.ts +1 -0
  75. package/dist/server/routes/index.d.ts.map +1 -1
  76. package/dist/server/routes/index.js +2 -0
  77. package/dist/server/routes/invitations.d.ts +1 -1
  78. package/dist/server/routes/invitations.d.ts.map +1 -1
  79. package/dist/server/routes/invitations.js +12 -11
  80. package/dist/server/routes/user_management_users.d.ts +1 -1
  81. package/package.json +17 -13
package/README.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  A reusable authentication UI component package powered by Next.js, TailwindCSS, and shadcn. It integrates `hazo_config` for configuration management and `hazo_connect` for data access, enabling future components to stay aligned with platform conventions.
4
4
 
5
+ ### What's New in v10.1.0 🚀
6
+
7
+ **Incremental Google OAuth Scopes & Server-Side Token Access** — Grant additional Google API scopes (Analytics, Sheets, Drive, etc.) without creating a separate OAuth app. Store encrypted tokens server-side and access them programmatically.
8
+
9
+ **New Features:**
10
+ - **`hazo_google_oauth_tokens` table** (migration 021) — Encrypted AES-256-GCM storage for Google OAuth tokens with scope tracking
11
+ - **Token Service Exports** from `hazo_auth/server-lib`:
12
+ - `store_google_oauth_token(user_id, tokens, scopes)` — Save OAuth tokens after user grants new scopes
13
+ - `getGoogleToken(user_id, opts?)` — Get a fresh access token (refreshes if expired)
14
+ - `revoke_google_oauth_token(user_id)` — Permanently revoke stored token and forget scopes
15
+ - `get_google_token_status(user_id)` — Check connection status, scopes, and expiry
16
+ - **Client Helper** — `requestGoogleScopes(scopes, opts?)` from `hazo_auth/client` triggers Google consent prompt for incremental scopes
17
+ - **HTTP Routes:**
18
+ - `GET /api/hazo_auth/google/token` — Returns `{ connected, scopes, expires_at }`
19
+ - `DELETE /api/hazo_auth/google/token` — Revoke stored token without signing user out
20
+ - **NextAuth Config Updates** — Captures `refresh_token` and adds `include_granted_scopes: true` parameter for incremental scope flow
21
+ - **Route Handler Exports** from `hazo_auth/server/routes`:
22
+ - `googleTokenGET` — implements GET status endpoint
23
+ - `googleTokenDELETE` — implements DELETE revoke endpoint
24
+
25
+ **Setup:**
26
+ ```bash
27
+ # 1. Run the migration
28
+ npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql
29
+
30
+ # 2. Set encryption environment variables
31
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
32
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
33
+
34
+ # 3. Install optional peer (for token encryption)
35
+ npm install hazo_secure
36
+ ```
37
+
38
+ **Client Usage:**
39
+ ```tsx
40
+ import { requestGoogleScopes } from "hazo_auth/client";
41
+
42
+ <button onClick={() => requestGoogleScopes([
43
+ "https://www.googleapis.com/auth/analytics.readonly"
44
+ ])}>
45
+ Connect Google Analytics
46
+ </button>
47
+ ```
48
+
49
+ **Server Usage:**
50
+ ```ts
51
+ import { getGoogleToken } from "hazo_auth/server-lib";
52
+
53
+ const result = await getGoogleToken(userId, {
54
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
55
+ });
56
+ if (result.ok) {
57
+ // use result.access_token to call Google APIs
58
+ }
59
+ ```
60
+
61
+ See [Google API Access (Incremental Scopes)](#google-api-access-incremental-scopes) below for full details.
62
+
5
63
  ### What's New in v9.1.1 🔧
6
64
 
7
65
  **Dev-server noise fixes for Next.js 16 + Turbopack**
@@ -742,9 +800,7 @@ CREATE INDEX idx_hazo_scopes_level ON hazo_scopes(level);
742
800
  CREATE INDEX idx_hazo_scopes_slug ON hazo_scopes(slug);
743
801
 
744
802
  -- 7a. Reserved system scopes
745
- INSERT INTO hazo_scopes (id, parent_id, name, level)
746
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system')
747
- ON CONFLICT (id) DO NOTHING;
803
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
748
804
  INSERT INTO hazo_scopes (id, parent_id, name, level)
749
805
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default')
750
806
  ON CONFLICT (id) DO NOTHING;
@@ -900,8 +956,7 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
900
956
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
901
957
 
902
958
  -- Reserved system scopes
903
- INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
904
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', datetime('now'), datetime('now'));
959
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
905
960
  INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
906
961
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));
907
962
 
@@ -1254,6 +1309,48 @@ Google OAuth adds one new dependency:
1254
1309
  - Check logs for `invitation_table_missing` warnings
1255
1310
  - If using custom paths, set `create_firm_url` to your app's create firm page URL
1256
1311
 
1312
+ ### Google API Access (Incremental Scopes)
1313
+
1314
+ `hazo_auth` v10.1+ supports granting additional Google API scopes (Analytics, Sheets, etc.) without a separate OAuth app.
1315
+
1316
+ **Setup:**
1317
+ 1. Run the migration: `npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql`
1318
+ 2. Set encryption env vars:
1319
+ ```bash
1320
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
1321
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
1322
+ ```
1323
+ 3. Install the optional peer: `npm install hazo_secure`
1324
+
1325
+ **Usage:**
1326
+
1327
+ ```tsx
1328
+ // Client component — triggers Google consent for extra scopes
1329
+ import { requestGoogleScopes } from "hazo_auth/client";
1330
+
1331
+ <button onClick={() => requestGoogleScopes([
1332
+ "https://www.googleapis.com/auth/analytics.readonly"
1333
+ ])}>
1334
+ Connect Analytics
1335
+ </button>
1336
+ ```
1337
+
1338
+ ```ts
1339
+ // Server action / API route — get a fresh access token
1340
+ import { getGoogleToken } from "hazo_auth/server-lib";
1341
+
1342
+ const result = await getGoogleToken(userId, {
1343
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
1344
+ });
1345
+ if (result.ok) {
1346
+ // use result.access_token to call Google APIs
1347
+ }
1348
+ ```
1349
+
1350
+ The incremental scope token is stored and revoked independently of the user's sign-in session. `DELETE /api/hazo_auth/google/token` removes the stored token without signing the user out.
1351
+
1352
+ **Status endpoint:** `GET /api/hazo_auth/google/token` returns `{ connected, scopes, expires_at }`.
1353
+
1257
1354
  ---
1258
1355
 
1259
1356
  ## Using Components
@@ -1743,7 +1840,6 @@ type TenantOrganization = {
1743
1840
  slug: string | null; // URL-friendly identifier
1744
1841
  level: string; // "Company", "Division", etc.
1745
1842
  role_id: string; // User's role in this scope
1746
- is_super_admin: boolean;
1747
1843
  branding?: {
1748
1844
  logo_url: string | null;
1749
1845
  primary_color: string | null;
@@ -2500,6 +2596,28 @@ import { ProfilePicMenu } from "hazo_auth/components/layouts/shared";
2500
2596
  />
2501
2597
  ```
2502
2598
 
2599
+ #### Menu item types
2600
+
2601
+ `custom_menu_items` accepts four `type` values:
2602
+
2603
+ | Type | Renders as | Fields used |
2604
+ |------|------------|-------------|
2605
+ | `info` | Read-only label/value row | `label`, `value` |
2606
+ | `link` | Navigation link | `label`, `href` |
2607
+ | `separator` | Divider | — |
2608
+ | `action` | Clickable item that fires a client-side callback | `label`, `onSelect` |
2609
+
2610
+ ```typescript
2611
+ <ProfilePicMenu
2612
+ variant="dropdown"
2613
+ custom_menu_items={[
2614
+ { type: "action", label: "Switch workspace", onSelect: () => openSwitcher(), order: 1, id: "switch" }
2615
+ ]}
2616
+ />
2617
+ ```
2618
+
2619
+ > **`action` items are React-only.** Because `onSelect` is a function, action items cannot be expressed via the INI `custom_menu_items` config (which only supports the serialisable `info` / `link` / `separator` types) — pass them through the `custom_menu_items` prop instead.
2620
+
2503
2621
  ### Configuration
2504
2622
 
2505
2623
  ```ini
@@ -544,9 +544,7 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
544
544
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
545
545
 
546
546
  -- Reserved system scopes
547
- INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
548
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', datetime('now'), datetime('now'));
549
-
547
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
550
548
  INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
551
549
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));
552
550
 
@@ -733,10 +731,7 @@ CREATE INDEX idx_hazo_scopes_level ON hazo_scopes(level);
733
731
  CREATE INDEX idx_hazo_scopes_slug ON hazo_scopes(slug);
734
732
 
735
733
  -- 7a. Reserved system scopes
736
- INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
737
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', NOW(), NOW())
738
- ON CONFLICT (id) DO NOTHING;
739
-
734
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
740
735
  INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
741
736
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', NOW(), NOW())
742
737
  ON CONFLICT (id) DO NOTHING;
@@ -897,8 +892,8 @@ GRANT USAGE ON TYPE hazo_enum_invitation_status TO anon, authenticated;
897
892
  - [ ] `hazo_invitations`
898
893
  - [ ] `hazo_user_relationships` (managed sub-profile parent/child links)
899
894
  - [ ] Reserved system scopes inserted:
900
- - [ ] `00000000-0000-0000-0000-000000000000` (Super Admin)
901
895
  - [ ] `00000000-0000-0000-0000-000000000001` (System / non-multi-tenancy default)
896
+ - [ ] Migration 020 run: Super Admin scope retired, `hazo_org_global_admin` permission granted to affected roles
902
897
  - [ ] `firm_admin` role inserted into `hazo_roles`
903
898
 
904
899
  ---
@@ -1138,6 +1133,24 @@ ls app/api/hazo_auth/set_password/route.ts
1138
1133
  - [ ] OAuth API routes created (`[...nextauth]`, `oauth/google/callback`, `set_password`)
1139
1134
  - [ ] Post-login redirect configured (if not using invitations, set `skip_invitation_check = true`)
1140
1135
 
1136
+ #### Google API Token Storage (optional — required for `getGoogleToken`)
1137
+
1138
+ Only needed if you use `requestGoogleScopes` / `getGoogleToken` for Google API access beyond sign-in:
1139
+
1140
+ 1. Install optional peer: `npm install hazo_secure`
1141
+ 2. Run migration: `npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql`
1142
+ 3. Set env vars:
1143
+ ```env
1144
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
1145
+ HAZO_AUTH_OAUTH_KEY_V1=<base64-32-bytes from: openssl rand -base64 32>
1146
+ ```
1147
+ 4. Wire the new routes in your Next.js app (same pattern as other hazo_auth routes):
1148
+ ```ts
1149
+ // app/api/hazo_auth/google/token/route.ts
1150
+ export { GET as googleTokenGET, DELETE as googleTokenDELETE } from "hazo_auth/server/routes";
1151
+ export const dynamic = "force-dynamic";
1152
+ ```
1153
+
1141
1154
  ---
1142
1155
 
1143
1156
  ## Phase 4: API Routes
@@ -1768,10 +1781,7 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
1768
1781
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
1769
1782
 
1770
1783
  -- 3. Create system scopes
1771
- INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
1772
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', NOW(), NOW())
1773
- ON CONFLICT (id) DO NOTHING;
1774
-
1784
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
1775
1785
  INSERT INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
1776
1786
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', NOW(), NOW())
1777
1787
  ON CONFLICT (id) DO NOTHING;
@@ -1861,9 +1871,7 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
1861
1871
  CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
1862
1872
 
1863
1873
  -- 3. Create system scopes
1864
- INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
1865
- VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', datetime('now'), datetime('now'));
1866
-
1874
+ -- Note: Super Admin scope retired in v10; use hazo_org_global_admin permission instead (see migration 020)
1867
1875
  INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
1868
1876
  VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));
1869
1877
 
@@ -1959,8 +1967,8 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
1959
1967
  - [ ] `hazo_user_scopes` (user-scope-role assignments)
1960
1968
  - [ ] `hazo_invitations` (user invitation flow)
1961
1969
  - [ ] System scopes exist:
1962
- - [ ] Super Admin scope (00000000-0000-0000-0000-000000000000)
1963
1970
  - [ ] Default System scope (00000000-0000-0000-0000-000000000001)
1971
+ - [ ] Migration 020 run: Super Admin scope retired, `hazo_org_global_admin` permission granted
1964
1972
  - [ ] Grants applied (PostgreSQL)
1965
1973
  - [ ] HRBAC tabs visible in User Management
1966
1974
  - [ ] Scope test page works
@@ -5,7 +5,8 @@ import { createCrudService } from "hazo_connect/server";
5
5
  import { get_user_management_config } from "../lib/user_management_config.server.js";
6
6
  import { get_config_value } from "../lib/config/config_loader.server.js";
7
7
  import { create_app_logger } from "../lib/app_logger.js";
8
- import { SUPER_ADMIN_SCOPE_ID } from "../lib/services/scope_service.js";
8
+ import { DEFAULT_SYSTEM_SCOPE_ID } from "../lib/services/scope_service.js";
9
+ import { GLOBAL_ADMIN_PERMISSION } from "../lib/constants.js";
9
10
 
10
11
  // section: types
11
12
  type InitSummary = {
@@ -23,10 +24,6 @@ type InitSummary = {
23
24
  existing: number;
24
25
  };
25
26
  // v5.x: Removed user_role - roles are now assigned via hazo_user_scopes
26
- super_admin_scope: {
27
- inserted: boolean;
28
- existing: boolean;
29
- };
30
27
  user_scope: {
31
28
  inserted: boolean;
32
29
  existing: boolean;
@@ -77,23 +74,13 @@ function print_summary(summary: InitSummary): void {
77
74
 
78
75
  // v5.x: User-Role assignments are now handled via User-Scope assignments (see below)
79
76
 
80
- // Super admin scope summary
81
- console.log("Super Admin Scope:");
82
- if (summary.super_admin_scope.inserted) {
83
- console.log(` ✓ Inserted: Super Admin scope (ID: ${SUPER_ADMIN_SCOPE_ID})`);
84
- }
85
- if (summary.super_admin_scope.existing) {
86
- console.log(` ⊙ Already existed: Super Admin scope (ID: ${SUPER_ADMIN_SCOPE_ID})`);
87
- }
88
- console.log();
89
-
90
77
  // User scope summary
91
78
  console.log("User-Scope Assignment:");
92
79
  if (summary.user_scope.inserted) {
93
- console.log(` ✓ Inserted: User assigned to Super Admin scope`);
80
+ console.log(` ✓ Inserted: User assigned to default system scope`);
94
81
  }
95
82
  if (summary.user_scope.existing) {
96
- console.log(` ⊙ Already existed: User already in Super Admin scope`);
83
+ console.log(` ⊙ Already existed: User already in default system scope`);
97
84
  }
98
85
  console.log();
99
86
 
@@ -131,10 +118,6 @@ export async function handle_init_users(options: InitUsersOptions = {}): Promise
131
118
  existing: 0,
132
119
  },
133
120
  // v5.x: Removed user_role - roles are now assigned via hazo_user_scopes
134
- super_admin_scope: {
135
- inserted: false,
136
- existing: false,
137
- },
138
121
  user_scope: {
139
122
  inserted: false,
140
123
  existing: false,
@@ -155,7 +138,6 @@ export async function handle_init_users(options: InitUsersOptions = {}): Promise
155
138
  });
156
139
  const users_service = createCrudService(hazoConnect, "hazo_users");
157
140
  // v5.x: Removed hazo_user_roles - roles are now assigned via hazo_user_scopes
158
- const scopes_service = createCrudService(hazoConnect, "hazo_scopes");
159
141
  // hazo_user_scopes uses composite primary key (user_id, scope_id), no 'id' column
160
142
  const user_scopes_service = createCrudService(hazoConnect, "hazo_user_scopes", {
161
143
  primaryKeys: ["user_id", "scope_id"],
@@ -317,54 +299,63 @@ export async function handle_init_users(options: InitUsersOptions = {}): Promise
317
299
  console.log(`✓ Found user: ${super_user_email} (ID: ${user_id})`);
318
300
  console.log();
319
301
 
320
- // v5.x: Step 7 removed - role assignment now happens via hazo_user_scopes (see step 9)
321
-
322
- // 8. Ensure super admin scope exists
323
- const existing_scopes = await scopes_service.findBy({ id: SUPER_ADMIN_SCOPE_ID });
324
-
325
- if (Array.isArray(existing_scopes) && existing_scopes.length > 0) {
326
- summary.super_admin_scope.existing = true;
327
- console.log(`✓ Super Admin scope already exists (ID: ${SUPER_ADMIN_SCOPE_ID})`);
328
- } else {
329
- await scopes_service.insert({
330
- id: SUPER_ADMIN_SCOPE_ID,
331
- parent_id: null,
332
- name: "Super Admin",
333
- level: "system",
302
+ // 7. Ensure hazo_org_global_admin is in the permission catalog
303
+ const global_admin_perms = await permissions_service.findBy({
304
+ permission_name: GLOBAL_ADMIN_PERMISSION,
305
+ });
306
+ if (!Array.isArray(global_admin_perms) || global_admin_perms.length === 0) {
307
+ await permissions_service.insert({
308
+ permission_name: GLOBAL_ADMIN_PERMISSION,
309
+ description: "Global admin access to all scopes and operations",
334
310
  created_at: now,
335
311
  changed_at: now,
336
312
  });
337
- summary.super_admin_scope.inserted = true;
338
- console.log(`✓ Created Super Admin scope (ID: ${SUPER_ADMIN_SCOPE_ID})`);
313
+ console.log(`✓ Created permission: ${GLOBAL_ADMIN_PERMISSION}`);
314
+ } else {
315
+ console.log(`✓ Permission already exists: ${GLOBAL_ADMIN_PERMISSION}`);
339
316
  }
317
+ console.log();
340
318
 
319
+ // 9. Ensure hazo_org_global_admin is assigned to the super user role
320
+ // (The role already has all configured permissions; this ensures the global admin perm is included)
321
+ const perm_row = await permissions_service.findBy({ permission_name: GLOBAL_ADMIN_PERMISSION });
322
+ const perm_id = Array.isArray(perm_row) && perm_row.length > 0 ? (perm_row[0].id as string) : null;
323
+ if (perm_id && role_id) {
324
+ const existing_rp = await role_permissions_service.findBy({ role_id, permission_id: perm_id });
325
+ if (!Array.isArray(existing_rp) || existing_rp.length === 0) {
326
+ await role_permissions_service.insert({ role_id, permission_id: perm_id });
327
+ console.log(`✓ Assigned ${GLOBAL_ADMIN_PERMISSION} to super user role`);
328
+ } else {
329
+ console.log(`✓ Super user role already has ${GLOBAL_ADMIN_PERMISSION}`);
330
+ }
331
+ }
341
332
  console.log();
342
333
 
343
- // 9. Assign user to super admin scope
334
+ // 10. Assign user to DEFAULT_SYSTEM_SCOPE_ID (global access comes from the permission, not the scope)
344
335
  const existing_user_scopes = await user_scopes_service.findBy({
345
336
  user_id,
346
- scope_id: SUPER_ADMIN_SCOPE_ID,
337
+ scope_id: DEFAULT_SYSTEM_SCOPE_ID,
347
338
  });
348
339
 
349
340
  if (Array.isArray(existing_user_scopes) && existing_user_scopes.length > 0) {
350
341
  summary.user_scope.existing = true;
351
- console.log(`✓ User already assigned to Super Admin scope`);
342
+ console.log(`✓ User already assigned to default system scope`);
352
343
  } else {
353
344
  await user_scopes_service.insert({
354
345
  user_id,
355
- scope_id: SUPER_ADMIN_SCOPE_ID,
356
- root_scope_id: SUPER_ADMIN_SCOPE_ID,
346
+ scope_id: DEFAULT_SYSTEM_SCOPE_ID,
347
+ root_scope_id: DEFAULT_SYSTEM_SCOPE_ID,
357
348
  role_id,
358
349
  created_at: now,
359
350
  changed_at: now,
360
351
  });
361
352
  summary.user_scope.inserted = true;
362
- console.log(`✓ Assigned user to Super Admin scope`);
353
+ console.log(`✓ Assigned user to default system scope`);
363
354
  }
364
355
 
365
356
  console.log();
366
357
 
367
- // 10. Print summary
358
+ // 11. Print summary
368
359
  print_summary(summary);
369
360
 
370
361
  logger.info("init_users_completed", {
@@ -402,15 +393,16 @@ export function show_init_users_help(): void {
402
393
  console.log(`
403
394
  hazo_auth init-users
404
395
 
405
- Initialize users, roles, permissions, and super admin scope from configuration.
396
+ Initialize users, roles, and permissions from configuration.
406
397
 
407
398
  This command reads from hazo_auth_config.ini and:
408
399
  1. Creates permissions from [hazo_auth__user_management] application_permission_list_defaults
409
400
  2. Creates a 'default_super_user_role' role
410
401
  3. Assigns all permissions to the super user role
411
402
  4. Finds user by email (from --email parameter or config)
412
- 5. Creates the Super Admin scope (${SUPER_ADMIN_SCOPE_ID})
413
- 6. Assigns the user to the Super Admin scope with the super user role
403
+ 5. Ensures the '${GLOBAL_ADMIN_PERMISSION}' permission exists and is assigned to the super user role
404
+ 6. Assigns the user to the default system scope (${DEFAULT_SYSTEM_SCOPE_ID})
405
+ Global admin access is granted via the '${GLOBAL_ADMIN_PERMISSION}' permission, not by scope
414
406
  (v5.x: Roles are assigned per-scope via hazo_user_scopes table)
415
407
 
416
408
  Options:
@@ -24,7 +24,6 @@ export type HazoAuthUser = {
24
24
  export type ScopeAccessInfo = {
25
25
  scope_id: string;
26
26
  scope_name?: string;
27
- is_super_admin?: boolean;
28
27
  };
29
28
 
30
29
  /**
@@ -135,7 +134,6 @@ export type TenantOrganization = {
135
134
  slug: string | null;
136
135
  level: string;
137
136
  role_id: string;
138
- is_super_admin: boolean;
139
137
  branding?: {
140
138
  logo_url: string | null;
141
139
  primary_color: string | null;
@@ -30,6 +30,7 @@ import {
30
30
  } from "../services/user_scope_service.js";
31
31
  import { get_cookie_name, BASE_COOKIE_NAMES } from "../cookies_config.server.js";
32
32
  import { get_app_permission_descriptions } from "../app_permissions_config.server.js";
33
+ import { GLOBAL_ADMIN_PERMISSION } from "../constants.js";
33
34
 
34
35
  // section: helpers
35
36
 
@@ -383,7 +384,6 @@ async function check_scope_access_internal(
383
384
  scope_access_via: {
384
385
  scope_id: result.access_via.scope_id,
385
386
  scope_name: result.access_via.scope_name,
386
- is_super_admin: result.is_super_admin,
387
387
  },
388
388
  user_scopes,
389
389
  };
@@ -587,31 +587,37 @@ export async function hazo_get_auth(
587
587
  const hrbac_enabled = is_hrbac_enabled();
588
588
 
589
589
  if (hrbac_enabled && options?.scope_id) {
590
- const scope_result = await check_scope_access_internal(
591
- user.id,
592
- options.scope_id,
593
- );
594
-
595
- scope_ok = scope_result.scope_ok;
596
- scope_access_via = scope_result.scope_access_via;
597
-
598
- // Log scope denial if permission logging is enabled
599
- if (!scope_ok && config.log_permission_denials) {
600
- const client_ip = get_client_ip(request);
601
- logger.warn("auth_utility_scope_access_denied", {
602
- filename: get_filename(),
603
- line_number: get_line_number(),
604
- user_id: user.id,
605
- scope_id: options.scope_id,
606
- user_scopes: scope_result.user_scopes,
607
- ip: client_ip,
608
- correlation_id: getCorrelationId(),
609
- });
610
- }
590
+ // Global admin permission grants access to all scopes
591
+ const has_global_admin = permissions.includes(GLOBAL_ADMIN_PERMISSION);
592
+ if (has_global_admin) {
593
+ scope_ok = true;
594
+ scope_access_via = { scope_id: options.scope_id };
595
+ } else {
596
+ const scope_result = await check_scope_access_internal(
597
+ user.id,
598
+ options.scope_id,
599
+ );
600
+ scope_ok = scope_result.scope_ok;
601
+ scope_access_via = scope_result.scope_access_via;
602
+
603
+ // Log scope denial if permission logging is enabled
604
+ if (!scope_ok && config.log_permission_denials) {
605
+ const client_ip = get_client_ip(request);
606
+ logger.warn("auth_utility_scope_access_denied", {
607
+ filename: get_filename(),
608
+ line_number: get_line_number(),
609
+ user_id: user.id,
610
+ scope_id: options.scope_id,
611
+ user_scopes: scope_result.user_scopes,
612
+ ip: client_ip,
613
+ correlation_id: getCorrelationId(),
614
+ });
615
+ }
611
616
 
612
- // Throw error if strict mode and scope access denied
613
- if (!scope_ok && options.strict) {
614
- throw new ScopeAccessError(options.scope_id, scope_result.user_scopes);
617
+ // Throw error if strict mode and scope access denied
618
+ if (!scope_ok && options.strict) {
619
+ throw new ScopeAccessError(options.scope_id, scope_result.user_scopes);
620
+ }
615
621
  }
616
622
  }
617
623
 
@@ -7,6 +7,7 @@ import { NextRequest } from "next/server";
7
7
  import { hazo_get_auth } from "./hazo_get_auth.server.js";
8
8
  import { get_auth_cache } from "./auth_cache.js";
9
9
  import { get_scope_by_id } from "../services/scope_service.js";
10
+ import { GLOBAL_ADMIN_PERMISSION } from "../constants.js";
10
11
  import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
11
12
  import { get_cookie_name } from "../cookies_config.server.js";
12
13
  import { get_auth_utility_config } from "../auth_utility_config.server.js";
@@ -62,14 +63,12 @@ export function extract_scope_id_from_request(
62
63
  }
63
64
 
64
65
  /**
65
- * Builds TenantOrganization from scope details and access info
66
+ * Builds TenantOrganization from scope details
66
67
  * @param scope_details - Full scope details from cache
67
- * @param is_super_admin - Whether user is accessing as super admin
68
68
  * @returns TenantOrganization object
69
69
  */
70
70
  function build_tenant_organization(
71
71
  scope_details: ScopeDetails,
72
- is_super_admin: boolean,
73
72
  ): TenantOrganization {
74
73
  return {
75
74
  id: scope_details.id,
@@ -77,7 +76,6 @@ function build_tenant_organization(
77
76
  slug: scope_details.slug,
78
77
  level: scope_details.level,
79
78
  role_id: scope_details.role_id,
80
- is_super_admin,
81
79
  branding:
82
80
  scope_details.logo_url || scope_details.primary_color
83
81
  ? {
@@ -159,18 +157,17 @@ export async function hazo_get_tenant_auth(
159
157
  let organization: TenantOrganization | null = null;
160
158
 
161
159
  if (scope_id && auth_result.scope_ok && auth_result.scope_access_via) {
162
- // Find the scope in user's scopes that matches the access_via scope
160
+ // Try to find the scope in user's cached scope assignments first.
161
+ // For global admins the scope may not be in their cache (they can access any scope),
162
+ // in which case we fall through to the permission-based fetch below.
163
163
  const access_scope = user_scopes.find(
164
164
  (s) => s.id === auth_result.scope_access_via?.scope_id,
165
165
  );
166
166
 
167
167
  if (access_scope) {
168
- organization = build_tenant_organization(
169
- access_scope,
170
- auth_result.scope_access_via.is_super_admin || false,
171
- );
172
- } else if (auth_result.scope_access_via.is_super_admin) {
173
- // Super admin accessing scope they're not assigned to - fetch scope details
168
+ organization = build_tenant_organization(access_scope);
169
+ } else if (auth_result.permissions.includes(GLOBAL_ADMIN_PERMISSION)) {
170
+ // Global admin accessing a scope they aren't directly assigned to — fetch scope details
174
171
  const hazoConnect = get_hazo_connect_instance();
175
172
  const scope_result = await get_scope_by_id(hazoConnect, scope_id);
176
173
  if (scope_result.success && scope_result.scope) {
@@ -179,8 +176,7 @@ export async function hazo_get_tenant_auth(
179
176
  name: scope_result.scope.name,
180
177
  slug: null, // Could fetch from scope if slug column exists
181
178
  level: scope_result.scope.level,
182
- role_id: "", // Super admin doesn't have a role in the scope
183
- is_super_admin: true,
179
+ role_id: "", // Global admin doesn't have a role assignment in the scope
184
180
  branding: scope_result.scope.logo_url
185
181
  ? {
186
182
  logo_url: scope_result.scope.logo_url,
@@ -11,6 +11,7 @@ import { get_oauth_config } from "../oauth_config.server.js";
11
11
  import { handle_google_oauth_login } from "../services/oauth_service.js";
12
12
  import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
13
13
  import { create_app_logger } from "../app_logger.js";
14
+ import { store_google_oauth_token, GoogleTokenStorageUnconfigured } from "../services/google_token_service.js";
14
15
 
15
16
  // section: types
16
17
  export type NextAuthCallbackUser = {
@@ -27,6 +28,8 @@ export type NextAuthCallbackAccount = {
27
28
  access_token?: string;
28
29
  id_token?: string;
29
30
  expires_at?: number;
31
+ refresh_token?: string;
32
+ scope?: string;
30
33
  };
31
34
 
32
35
  export type NextAuthCallbackProfile = {
@@ -62,6 +65,7 @@ export function get_nextauth_config(): AuthOptions {
62
65
  prompt: "consent",
63
66
  access_type: "offline",
64
67
  response_type: "code",
68
+ include_granted_scopes: "true",
65
69
  },
66
70
  },
67
71
  })
@@ -170,6 +174,43 @@ export function get_nextauth_config(): AuthOptions {
170
174
  // Store user_id in account for the JWT callback to pick up
171
175
  (account as Record<string, unknown>).hazo_user_id = result.user_id;
172
176
 
177
+ // Capture refresh token when extra scopes were granted
178
+ const BASE_SCOPES = ["openid", "email", "profile"];
179
+ const granted_scopes = (account.scope ?? "").split(" ").filter(Boolean);
180
+ const has_extra_scopes = granted_scopes.some(s => !BASE_SCOPES.includes(s));
181
+
182
+ if (account.refresh_token && has_extra_scopes) {
183
+ try {
184
+ await store_google_oauth_token({
185
+ user_id: result.user_id!,
186
+ refresh_token: account.refresh_token,
187
+ access_token: account.access_token,
188
+ scopes: account.scope ?? "",
189
+ expires_at: account.expires_at
190
+ ? new Date(account.expires_at * 1000).toISOString()
191
+ : undefined,
192
+ });
193
+ logger.info("nextauth_google_token_stored", {
194
+ user_id: result.user_id,
195
+ scopes: account.scope,
196
+ });
197
+ } catch (tokenError) {
198
+ if (tokenError instanceof GoogleTokenStorageUnconfigured) {
199
+ logger.error("nextauth_google_token_storage_unconfigured", {
200
+ user_id: result.user_id,
201
+ error: tokenError.message,
202
+ });
203
+ return "/api/auth/error?error=GoogleTokenStorageUnconfigured";
204
+ }
205
+ const errorMsg = tokenError instanceof Error ? tokenError.message : String(tokenError);
206
+ logger.error("nextauth_google_token_store_failed", {
207
+ user_id: result.user_id,
208
+ error: errorMsg,
209
+ });
210
+ // Non-fatal: continue sign-in even if token storage fails
211
+ }
212
+ }
213
+
173
214
  return true;
174
215
  } catch (error) {
175
216
  const errorMessage = error instanceof Error ? error.message : "Unknown error";