hazo_auth 5.1.10 → 5.1.12
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/README.md +206 -2
- package/SETUP_CHECKLIST.md +202 -1
- package/cli-src/lib/auth/auth_cache.ts +24 -1
- package/cli-src/lib/auth/auth_types.ts +142 -0
- package/cli-src/lib/auth/hazo_get_auth.server.ts +60 -8
- package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +265 -0
- package/cli-src/lib/auth/index.ts +20 -0
- package/cli-src/lib/cookies_config.server.ts +1 -0
- package/dist/components/layouts/user_management/components/user_scopes_tab.d.ts.map +1 -1
- package/dist/components/layouts/user_management/components/user_scopes_tab.js +39 -2
- package/dist/lib/auth/auth_cache.d.ts +11 -2
- package/dist/lib/auth/auth_cache.d.ts.map +1 -1
- package/dist/lib/auth/auth_cache.js +20 -1
- package/dist/lib/auth/auth_types.d.ts +109 -0
- package/dist/lib/auth/auth_types.d.ts.map +1 -1
- package/dist/lib/auth/auth_types.js +43 -0
- package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_auth.server.js +47 -8
- package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts +64 -0
- package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -0
- package/dist/lib/auth/hazo_get_tenant_auth.server.js +201 -0
- package/dist/lib/auth/index.d.ts +3 -0
- package/dist/lib/auth/index.d.ts.map +1 -1
- package/dist/lib/auth/index.js +3 -0
- package/dist/lib/cookies_config.server.d.ts +1 -0
- package/dist/lib/cookies_config.server.d.ts.map +1 -1
- package/dist/lib/cookies_config.server.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1296,7 +1296,13 @@ export default function Page() {
|
|
|
1296
1296
|
|
|
1297
1297
|
## Authentication Service
|
|
1298
1298
|
|
|
1299
|
-
The `hazo_auth` package provides a comprehensive authentication and authorization system with role-based access control (RBAC). The main authentication
|
|
1299
|
+
The `hazo_auth` package provides a comprehensive authentication and authorization system with role-based access control (RBAC). The main authentication utilities are:
|
|
1300
|
+
|
|
1301
|
+
- **`hazo_get_auth`** - Standard authentication with user details, permissions, and caching
|
|
1302
|
+
- **`hazo_get_tenant_auth`** - Tenant-aware authentication that extracts scope context from request headers or cookies
|
|
1303
|
+
- **`require_tenant_auth`** - Strict tenant authentication with typed error handling
|
|
1304
|
+
|
|
1305
|
+
These utilities provide user details, permissions, and permission checking with built-in caching and rate limiting.
|
|
1300
1306
|
|
|
1301
1307
|
### Client-Side API Endpoint (Recommended)
|
|
1302
1308
|
|
|
@@ -1461,7 +1467,205 @@ export async function proxy(request: NextRequest) {
|
|
|
1461
1467
|
|
|
1462
1468
|
### Server-Side Functions
|
|
1463
1469
|
|
|
1464
|
-
#### `
|
|
1470
|
+
#### `hazo_get_tenant_auth` (Recommended for Multi-Tenant Apps)
|
|
1471
|
+
|
|
1472
|
+
**New:** Tenant-aware authentication function that extracts scope context from request headers or cookies and returns enriched result with organization information.
|
|
1473
|
+
|
|
1474
|
+
**Location:** `src/lib/auth/hazo_get_tenant_auth.server.ts`
|
|
1475
|
+
|
|
1476
|
+
**Scope Context Extraction:**
|
|
1477
|
+
- **Header (priority):** `X-Hazo-Scope-Id` (configurable via `scope_header_name`)
|
|
1478
|
+
- **Cookie (fallback):** `hazo_auth_scope_id` (with prefix if configured)
|
|
1479
|
+
|
|
1480
|
+
**Function Signature:**
|
|
1481
|
+
```typescript
|
|
1482
|
+
import { hazo_get_tenant_auth } from "hazo_auth";
|
|
1483
|
+
import type { TenantAuthResult, TenantAuthOptions } from "hazo_auth";
|
|
1484
|
+
|
|
1485
|
+
async function hazo_get_tenant_auth(
|
|
1486
|
+
request: NextRequest,
|
|
1487
|
+
options?: TenantAuthOptions
|
|
1488
|
+
): Promise<TenantAuthResult>
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
**Options:**
|
|
1492
|
+
- `required_permissions?: string[]` - Array of permission names to check
|
|
1493
|
+
- `strict?: boolean` - If `true`, throws errors when checks fail (default: `false`)
|
|
1494
|
+
- `scope_header_name?: string` - Custom header name for scope ID (default: `"X-Hazo-Scope-Id"`)
|
|
1495
|
+
- `scope_cookie_name?: string` - Custom cookie name for scope ID (default: `"hazo_auth_scope_id"`)
|
|
1496
|
+
|
|
1497
|
+
**Return Type:**
|
|
1498
|
+
```typescript
|
|
1499
|
+
type TenantAuthResult =
|
|
1500
|
+
| {
|
|
1501
|
+
authenticated: true;
|
|
1502
|
+
user: HazoAuthUser;
|
|
1503
|
+
permissions: string[];
|
|
1504
|
+
permission_ok: boolean;
|
|
1505
|
+
missing_permissions?: string[];
|
|
1506
|
+
organization: TenantOrganization | null; // NEW: Tenant context
|
|
1507
|
+
user_scopes: ScopeDetails[]; // NEW: All user's scopes for switching
|
|
1508
|
+
scope_ok: boolean;
|
|
1509
|
+
}
|
|
1510
|
+
| {
|
|
1511
|
+
authenticated: false;
|
|
1512
|
+
user: null;
|
|
1513
|
+
permissions: [];
|
|
1514
|
+
permission_ok: false;
|
|
1515
|
+
organization: null;
|
|
1516
|
+
user_scopes: [];
|
|
1517
|
+
scope_ok: false;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
type TenantOrganization = {
|
|
1521
|
+
id: string;
|
|
1522
|
+
name: string;
|
|
1523
|
+
slug: string | null; // URL-friendly identifier
|
|
1524
|
+
level: string; // "Company", "Division", etc.
|
|
1525
|
+
role_id: string; // User's role in this scope
|
|
1526
|
+
is_super_admin: boolean;
|
|
1527
|
+
branding?: {
|
|
1528
|
+
logo_url: string | null;
|
|
1529
|
+
primary_color: string | null;
|
|
1530
|
+
secondary_color: string | null;
|
|
1531
|
+
tagline: string | null;
|
|
1532
|
+
};
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
type ScopeDetails = {
|
|
1536
|
+
id: string;
|
|
1537
|
+
name: string;
|
|
1538
|
+
slug: string | null;
|
|
1539
|
+
level: string;
|
|
1540
|
+
parent_id: string | null;
|
|
1541
|
+
role_id: string;
|
|
1542
|
+
logo_url: string | null;
|
|
1543
|
+
primary_color: string | null;
|
|
1544
|
+
secondary_color: string | null;
|
|
1545
|
+
tagline: string | null;
|
|
1546
|
+
};
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
**Basic Usage Example:**
|
|
1550
|
+
```typescript
|
|
1551
|
+
// app/api/dashboard/route.ts
|
|
1552
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
1553
|
+
import { hazo_get_tenant_auth } from "hazo_auth";
|
|
1554
|
+
|
|
1555
|
+
export async function GET(request: NextRequest) {
|
|
1556
|
+
const auth = await hazo_get_tenant_auth(request);
|
|
1557
|
+
|
|
1558
|
+
if (!auth.authenticated) {
|
|
1559
|
+
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (!auth.organization) {
|
|
1563
|
+
return NextResponse.json(
|
|
1564
|
+
{
|
|
1565
|
+
error: "No organization context",
|
|
1566
|
+
available_scopes: auth.user_scopes.map(s => ({ id: s.id, name: s.name }))
|
|
1567
|
+
},
|
|
1568
|
+
{ status: 403 }
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Access tenant-specific data
|
|
1573
|
+
const data = await getTenantData(auth.organization.id);
|
|
1574
|
+
|
|
1575
|
+
return NextResponse.json({
|
|
1576
|
+
organization: auth.organization,
|
|
1577
|
+
data,
|
|
1578
|
+
// Include available scopes for UI scope switcher
|
|
1579
|
+
available_scopes: auth.user_scopes,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
```
|
|
1583
|
+
|
|
1584
|
+
**Strict Mode with Error Handling:**
|
|
1585
|
+
```typescript
|
|
1586
|
+
import { require_tenant_auth, HazoAuthError } from "hazo_auth";
|
|
1587
|
+
|
|
1588
|
+
export async function GET(request: NextRequest) {
|
|
1589
|
+
try {
|
|
1590
|
+
const auth = await require_tenant_auth(request, {
|
|
1591
|
+
required_permissions: ["view_reports"],
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// auth.organization is guaranteed non-null here
|
|
1595
|
+
const reports = await getReports(auth.organization.id);
|
|
1596
|
+
return NextResponse.json({ reports });
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
if (error instanceof HazoAuthError) {
|
|
1599
|
+
return NextResponse.json(
|
|
1600
|
+
{
|
|
1601
|
+
error: error.message,
|
|
1602
|
+
code: error.code,
|
|
1603
|
+
// For TenantAccessDeniedError, includes available_scopes
|
|
1604
|
+
available_scopes: error.available_scopes
|
|
1605
|
+
},
|
|
1606
|
+
{ status: error.status_code }
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
throw error;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
**Frontend Integration:**
|
|
1615
|
+
```typescript
|
|
1616
|
+
// Client sets scope via header or cookie
|
|
1617
|
+
const response = await fetch("/api/dashboard", {
|
|
1618
|
+
headers: {
|
|
1619
|
+
"X-Hazo-Scope-Id": selectedScopeId,
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
// Or via cookie (set once during scope selection)
|
|
1624
|
+
document.cookie = `hazo_auth_scope_id=${selectedScopeId}; path=/`;
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
**Error Types:**
|
|
1628
|
+
```typescript
|
|
1629
|
+
import {
|
|
1630
|
+
AuthenticationRequiredError, // 401 - User not authenticated
|
|
1631
|
+
TenantRequiredError, // 403 - No tenant context provided
|
|
1632
|
+
TenantAccessDeniedError, // 403 - User lacks access to tenant
|
|
1633
|
+
} from "hazo_auth";
|
|
1634
|
+
```
|
|
1635
|
+
|
|
1636
|
+
#### `require_tenant_auth` (Strict Tenant Auth)
|
|
1637
|
+
|
|
1638
|
+
Helper function that wraps `hazo_get_tenant_auth` and throws typed errors for common failure cases.
|
|
1639
|
+
|
|
1640
|
+
**Throws:**
|
|
1641
|
+
- `AuthenticationRequiredError` (401) - User not authenticated
|
|
1642
|
+
- `TenantRequiredError` (403) - No tenant context in request
|
|
1643
|
+
- `TenantAccessDeniedError` (403) - User lacks access to requested tenant
|
|
1644
|
+
|
|
1645
|
+
**Returns:** `RequiredTenantAuthResult` with guaranteed non-null `organization`
|
|
1646
|
+
|
|
1647
|
+
**Example:**
|
|
1648
|
+
```typescript
|
|
1649
|
+
export async function GET(request: NextRequest) {
|
|
1650
|
+
try {
|
|
1651
|
+
// organization is guaranteed to exist
|
|
1652
|
+
const { organization, user, permissions } = await require_tenant_auth(request);
|
|
1653
|
+
|
|
1654
|
+
const data = await getData(organization.id);
|
|
1655
|
+
return NextResponse.json(data);
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
if (error instanceof HazoAuthError) {
|
|
1658
|
+
return NextResponse.json(
|
|
1659
|
+
{ error: error.message, code: error.code },
|
|
1660
|
+
{ status: error.status_code }
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
#### `hazo_get_auth` (Standard Auth)
|
|
1465
1669
|
|
|
1466
1670
|
The primary authentication utility for server-side use in API routes. Returns user details, permissions, and optionally checks required permissions.
|
|
1467
1671
|
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -1658,7 +1658,34 @@ WHERE table_name LIKE 'hazo_scopes_%'
|
|
|
1658
1658
|
sqlite3 data/hazo_auth.sqlite ".tables" | grep -E "hazo_scopes|hazo_user_scopes|hazo_scope_labels"
|
|
1659
1659
|
```
|
|
1660
1660
|
|
|
1661
|
-
### Step 7.5:
|
|
1661
|
+
### Step 7.5: Add Slug Column to hazo_scopes (Optional - for Tenant Auth)
|
|
1662
|
+
|
|
1663
|
+
If you plan to use tenant-aware authentication with URL-friendly identifiers, add the `slug` column to the `hazo_scopes` table:
|
|
1664
|
+
|
|
1665
|
+
```bash
|
|
1666
|
+
npm run migrate migrations/012_add_slug_to_hazo_scopes.sql
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
**Manual Migration (if needed):**
|
|
1670
|
+
|
|
1671
|
+
**PostgreSQL:**
|
|
1672
|
+
```sql
|
|
1673
|
+
ALTER TABLE hazo_scopes ADD COLUMN slug TEXT;
|
|
1674
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
**SQLite:**
|
|
1678
|
+
```sql
|
|
1679
|
+
ALTER TABLE hazo_scopes ADD COLUMN slug TEXT;
|
|
1680
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
**What is slug?**
|
|
1684
|
+
- URL-friendly identifier for scopes (e.g., "acme-corp", "sales-division")
|
|
1685
|
+
- Enables tenant context via URL paths (e.g., `/org/:slug/dashboard`)
|
|
1686
|
+
- Not enforced as unique to allow flexibility
|
|
1687
|
+
|
|
1688
|
+
### Step 7.6: Test HRBAC
|
|
1662
1689
|
|
|
1663
1690
|
1. Start your dev server: `npm run dev`
|
|
1664
1691
|
2. Log in with a user that has `admin_scope_hierarchy_management` permission
|
|
@@ -1675,11 +1702,185 @@ sqlite3 data/hazo_auth.sqlite ".tables" | grep -E "hazo_scopes|hazo_user_scopes|
|
|
|
1675
1702
|
- [ ] `hazo_scope_labels` (custom labels per org)
|
|
1676
1703
|
- [ ] Scope ID generator function created (PostgreSQL)
|
|
1677
1704
|
- [ ] Grants applied (PostgreSQL)
|
|
1705
|
+
- [ ] `slug` column added to hazo_scopes (optional, for tenant auth)
|
|
1678
1706
|
- [ ] HRBAC tabs visible in User Management
|
|
1679
1707
|
- [ ] Scope test page works
|
|
1680
1708
|
|
|
1681
1709
|
---
|
|
1682
1710
|
|
|
1711
|
+
## Phase 8: Tenant-Aware Authentication Setup (Optional)
|
|
1712
|
+
|
|
1713
|
+
Tenant-aware authentication adds scope context to authentication results, enabling multi-tenant applications where users can access multiple organizations/scopes.
|
|
1714
|
+
|
|
1715
|
+
**Skip this phase if:**
|
|
1716
|
+
- Your app doesn't need multi-tenancy
|
|
1717
|
+
- Users don't switch between different scopes/organizations
|
|
1718
|
+
|
|
1719
|
+
### Step 8.1: Ensure Slug Column Exists
|
|
1720
|
+
|
|
1721
|
+
The tenant auth feature uses the `slug` column for URL-friendly scope identifiers. If you didn't complete Step 7.5, do so now:
|
|
1722
|
+
|
|
1723
|
+
```bash
|
|
1724
|
+
npm run migrate migrations/012_add_slug_to_hazo_scopes.sql
|
|
1725
|
+
```
|
|
1726
|
+
|
|
1727
|
+
### Step 8.2: Use Tenant Auth in API Routes
|
|
1728
|
+
|
|
1729
|
+
Replace `hazo_get_auth` with `hazo_get_tenant_auth` in your API routes:
|
|
1730
|
+
|
|
1731
|
+
**Before (standard auth):**
|
|
1732
|
+
```typescript
|
|
1733
|
+
import { hazo_get_auth } from "hazo_auth";
|
|
1734
|
+
|
|
1735
|
+
export async function GET(request: NextRequest) {
|
|
1736
|
+
const auth = await hazo_get_auth(request);
|
|
1737
|
+
// No tenant context
|
|
1738
|
+
}
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
**After (tenant auth):**
|
|
1742
|
+
```typescript
|
|
1743
|
+
import { hazo_get_tenant_auth } from "hazo_auth";
|
|
1744
|
+
|
|
1745
|
+
export async function GET(request: NextRequest) {
|
|
1746
|
+
const auth = await hazo_get_tenant_auth(request);
|
|
1747
|
+
|
|
1748
|
+
if (auth.authenticated && auth.organization) {
|
|
1749
|
+
// auth.organization contains tenant details
|
|
1750
|
+
// auth.user_scopes contains all scopes user can access (for UI switcher)
|
|
1751
|
+
const data = await getTenantData(auth.organization.id);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
```
|
|
1755
|
+
|
|
1756
|
+
**Or use strict mode with error handling:**
|
|
1757
|
+
```typescript
|
|
1758
|
+
import { require_tenant_auth, HazoAuthError } from "hazo_auth";
|
|
1759
|
+
|
|
1760
|
+
export async function GET(request: NextRequest) {
|
|
1761
|
+
try {
|
|
1762
|
+
const auth = await require_tenant_auth(request);
|
|
1763
|
+
// auth.organization is guaranteed non-null
|
|
1764
|
+
return NextResponse.json(await getData(auth.organization.id));
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
if (error instanceof HazoAuthError) {
|
|
1767
|
+
return NextResponse.json(
|
|
1768
|
+
{ error: error.message, code: error.code },
|
|
1769
|
+
{ status: error.status_code }
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
throw error;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
```
|
|
1776
|
+
|
|
1777
|
+
### Step 8.3: Set Scope Context from Frontend
|
|
1778
|
+
|
|
1779
|
+
The frontend needs to send the current scope ID via header or cookie.
|
|
1780
|
+
|
|
1781
|
+
**Option A: Header (recommended - per-request):**
|
|
1782
|
+
```typescript
|
|
1783
|
+
const response = await fetch("/api/dashboard", {
|
|
1784
|
+
headers: {
|
|
1785
|
+
"X-Hazo-Scope-Id": selectedScopeId,
|
|
1786
|
+
},
|
|
1787
|
+
});
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
**Option B: Cookie (persistent - set once):**
|
|
1791
|
+
```typescript
|
|
1792
|
+
// Set cookie when user selects a scope
|
|
1793
|
+
document.cookie = `hazo_auth_scope_id=${selectedScopeId}; path=/`;
|
|
1794
|
+
|
|
1795
|
+
// Then all requests include the scope automatically
|
|
1796
|
+
const response = await fetch("/api/dashboard");
|
|
1797
|
+
```
|
|
1798
|
+
|
|
1799
|
+
**Custom Configuration (optional):**
|
|
1800
|
+
```typescript
|
|
1801
|
+
// Use custom header or cookie names
|
|
1802
|
+
const auth = await hazo_get_tenant_auth(request, {
|
|
1803
|
+
scope_header_name: "X-Tenant-Id", // Custom header
|
|
1804
|
+
scope_cookie_name: "my_app_tenant_id", // Custom cookie
|
|
1805
|
+
});
|
|
1806
|
+
```
|
|
1807
|
+
|
|
1808
|
+
### Step 8.4: Build Scope Switcher UI (Optional)
|
|
1809
|
+
|
|
1810
|
+
Use the `user_scopes` array to build a scope switcher dropdown:
|
|
1811
|
+
|
|
1812
|
+
```typescript
|
|
1813
|
+
const auth = await hazo_get_tenant_auth(request);
|
|
1814
|
+
|
|
1815
|
+
// Return available scopes to frontend
|
|
1816
|
+
return NextResponse.json({
|
|
1817
|
+
current_scope: auth.organization,
|
|
1818
|
+
available_scopes: auth.user_scopes.map(s => ({
|
|
1819
|
+
id: s.id,
|
|
1820
|
+
name: s.name,
|
|
1821
|
+
slug: s.slug,
|
|
1822
|
+
level: s.level,
|
|
1823
|
+
branding: {
|
|
1824
|
+
logo_url: s.logo_url,
|
|
1825
|
+
primary_color: s.primary_color,
|
|
1826
|
+
},
|
|
1827
|
+
})),
|
|
1828
|
+
});
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1831
|
+
**Frontend Scope Switcher:**
|
|
1832
|
+
```tsx
|
|
1833
|
+
function ScopeSwitcher({ scopes, currentScopeId }) {
|
|
1834
|
+
const handleScopeChange = (scopeId: string) => {
|
|
1835
|
+
// Option 1: Set cookie
|
|
1836
|
+
document.cookie = `hazo_auth_scope_id=${scopeId}; path=/`;
|
|
1837
|
+
window.location.reload();
|
|
1838
|
+
|
|
1839
|
+
// Option 2: Update state and send header on all requests
|
|
1840
|
+
setCurrentScope(scopeId);
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
return (
|
|
1844
|
+
<select value={currentScopeId} onChange={e => handleScopeChange(e.target.value)}>
|
|
1845
|
+
{scopes.map(scope => (
|
|
1846
|
+
<option key={scope.id} value={scope.id}>
|
|
1847
|
+
{scope.name} ({scope.level})
|
|
1848
|
+
</option>
|
|
1849
|
+
))}
|
|
1850
|
+
</select>
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
```
|
|
1854
|
+
|
|
1855
|
+
### Step 8.5: Test Tenant Auth
|
|
1856
|
+
|
|
1857
|
+
**Test with cURL:**
|
|
1858
|
+
```bash
|
|
1859
|
+
# No scope context - should return error or empty organization
|
|
1860
|
+
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN" \
|
|
1861
|
+
http://localhost:3000/api/dashboard | jq
|
|
1862
|
+
|
|
1863
|
+
# With scope context via header
|
|
1864
|
+
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN" \
|
|
1865
|
+
-H "X-Hazo-Scope-Id: SCOPE_UUID" \
|
|
1866
|
+
http://localhost:3000/api/dashboard | jq
|
|
1867
|
+
|
|
1868
|
+
# With scope context via cookie
|
|
1869
|
+
curl -H "Cookie: hazo_auth_session=YOUR_TOKEN; hazo_auth_scope_id=SCOPE_UUID" \
|
|
1870
|
+
http://localhost:3000/api/dashboard | jq
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
**Tenant Auth Checklist:**
|
|
1874
|
+
- [ ] `slug` column added to `hazo_scopes`
|
|
1875
|
+
- [ ] API routes updated to use `hazo_get_tenant_auth` or `require_tenant_auth`
|
|
1876
|
+
- [ ] Frontend sends scope context via header or cookie
|
|
1877
|
+
- [ ] Tenant auth returns organization details when scope is valid
|
|
1878
|
+
- [ ] Tenant auth returns user_scopes for building scope switcher
|
|
1879
|
+
- [ ] Error handling in place for missing/invalid scope context
|
|
1880
|
+
- [ ] Scope switcher UI built (optional but recommended)
|
|
1881
|
+
|
|
1882
|
+
---
|
|
1883
|
+
|
|
1683
1884
|
## Troubleshooting
|
|
1684
1885
|
|
|
1685
1886
|
### Issue: Email not sending
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// file_description: LRU cache implementation for hazo_get_auth with TTL and size limits
|
|
2
2
|
// section: imports
|
|
3
|
-
import type { HazoAuthUser } from "./auth_types";
|
|
3
|
+
import type { HazoAuthUser, ScopeDetails } from "./auth_types";
|
|
4
4
|
|
|
5
5
|
// section: types
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Cache entry structure
|
|
9
9
|
* v5.x: role_ids are now string UUIDs (from hazo_user_scopes)
|
|
10
|
+
* v5.2: Added scopes with full details for multi-tenancy support
|
|
10
11
|
*/
|
|
11
12
|
type CacheEntry = {
|
|
12
13
|
user: HazoAuthUser;
|
|
13
14
|
permissions: string[];
|
|
14
15
|
role_ids: string[];
|
|
16
|
+
scopes: ScopeDetails[]; // All user's scope assignments with full details
|
|
15
17
|
timestamp: number; // Unix timestamp in milliseconds
|
|
16
18
|
cache_version: number; // Version number for smart invalidation
|
|
17
19
|
};
|
|
@@ -83,12 +85,14 @@ class AuthCache {
|
|
|
83
85
|
* @param user - User data
|
|
84
86
|
* @param permissions - User permissions
|
|
85
87
|
* @param role_ids - User role IDs (v5.x: string UUIDs)
|
|
88
|
+
* @param scopes - User scope details with full information (v5.2+)
|
|
86
89
|
*/
|
|
87
90
|
set(
|
|
88
91
|
user_id: string,
|
|
89
92
|
user: HazoAuthUser,
|
|
90
93
|
permissions: string[],
|
|
91
94
|
role_ids: string[],
|
|
95
|
+
scopes: ScopeDetails[] = [],
|
|
92
96
|
): void {
|
|
93
97
|
// Evict LRU entries if cache is full
|
|
94
98
|
while (this.cache.size >= this.max_size) {
|
|
@@ -107,6 +111,7 @@ class AuthCache {
|
|
|
107
111
|
user,
|
|
108
112
|
permissions,
|
|
109
113
|
role_ids,
|
|
114
|
+
scopes,
|
|
110
115
|
timestamp: Date.now(),
|
|
111
116
|
cache_version,
|
|
112
117
|
};
|
|
@@ -155,6 +160,24 @@ class AuthCache {
|
|
|
155
160
|
this.cache.clear();
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Invalidates cache entries for users who have access to specific scopes
|
|
165
|
+
* Used when scope details change (name, branding, etc.)
|
|
166
|
+
* @param scope_ids - Array of scope IDs to invalidate
|
|
167
|
+
*/
|
|
168
|
+
invalidate_by_scope_ids(scope_ids: string[]): void {
|
|
169
|
+
const entries_to_remove: string[] = [];
|
|
170
|
+
for (const [user_id, entry] of this.cache.entries()) {
|
|
171
|
+
const has_scope = entry.scopes.some((s) => scope_ids.includes(s.id));
|
|
172
|
+
if (has_scope) {
|
|
173
|
+
entries_to_remove.push(user_id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const user_id of entries_to_remove) {
|
|
177
|
+
this.cache.delete(user_id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
158
181
|
/**
|
|
159
182
|
* Gets the maximum cache version for a set of roles
|
|
160
183
|
* Used to determine if cache entry is stale
|
|
@@ -100,3 +100,145 @@ export class ScopeAccessError extends Error {
|
|
|
100
100
|
this.name = "ScopeAccessError";
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
// section: scope_details_types
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Full scope details with branding information
|
|
108
|
+
* Used in cache and tenant auth results for multi-tenancy support
|
|
109
|
+
*/
|
|
110
|
+
export type ScopeDetails = {
|
|
111
|
+
id: string;
|
|
112
|
+
name: string;
|
|
113
|
+
slug: string | null;
|
|
114
|
+
level: string;
|
|
115
|
+
parent_id: string | null;
|
|
116
|
+
role_id: string;
|
|
117
|
+
// Branding fields (from hazo_scopes table)
|
|
118
|
+
logo_url: string | null;
|
|
119
|
+
primary_color: string | null;
|
|
120
|
+
secondary_color: string | null;
|
|
121
|
+
tagline: string | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Tenant/organization information returned in tenant auth results
|
|
126
|
+
* Simplified view of scope for API responses
|
|
127
|
+
*/
|
|
128
|
+
export type TenantOrganization = {
|
|
129
|
+
id: string;
|
|
130
|
+
name: string;
|
|
131
|
+
slug: string | null;
|
|
132
|
+
level: string;
|
|
133
|
+
role_id: string;
|
|
134
|
+
is_super_admin: boolean;
|
|
135
|
+
branding?: {
|
|
136
|
+
logo_url: string | null;
|
|
137
|
+
primary_color: string | null;
|
|
138
|
+
secondary_color: string | null;
|
|
139
|
+
tagline: string | null;
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options for hazo_get_tenant_auth function
|
|
145
|
+
* Extends HazoAuthOptions with tenant-specific configuration
|
|
146
|
+
*/
|
|
147
|
+
export type TenantAuthOptions = HazoAuthOptions & {
|
|
148
|
+
/**
|
|
149
|
+
* Header name to check for scope ID (default: "X-Hazo-Scope-Id")
|
|
150
|
+
*/
|
|
151
|
+
scope_header_name?: string;
|
|
152
|
+
/**
|
|
153
|
+
* Cookie name to check for scope ID (uses cookie prefix if not specified)
|
|
154
|
+
*/
|
|
155
|
+
scope_cookie_name?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Result type for hazo_get_tenant_auth function
|
|
160
|
+
* Extends HazoAuthResult with tenant-specific information
|
|
161
|
+
*/
|
|
162
|
+
export type TenantAuthResult =
|
|
163
|
+
| {
|
|
164
|
+
authenticated: true;
|
|
165
|
+
user: HazoAuthUser;
|
|
166
|
+
permissions: string[];
|
|
167
|
+
permission_ok: boolean;
|
|
168
|
+
missing_permissions?: string[];
|
|
169
|
+
organization: TenantOrganization | null;
|
|
170
|
+
user_scopes: ScopeDetails[];
|
|
171
|
+
scope_ok?: boolean;
|
|
172
|
+
scope_access_via?: ScopeAccessInfo;
|
|
173
|
+
}
|
|
174
|
+
| {
|
|
175
|
+
authenticated: false;
|
|
176
|
+
user: null;
|
|
177
|
+
permissions: [];
|
|
178
|
+
permission_ok: false;
|
|
179
|
+
organization: null;
|
|
180
|
+
user_scopes: [];
|
|
181
|
+
scope_ok?: false;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Guaranteed authenticated result with non-null organization
|
|
186
|
+
* Returned by require_tenant_auth when validation passes
|
|
187
|
+
*/
|
|
188
|
+
export type RequiredTenantAuthResult = TenantAuthResult & {
|
|
189
|
+
authenticated: true;
|
|
190
|
+
organization: TenantOrganization;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// section: tenant_error_classes
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Base error class for all hazo_auth errors
|
|
197
|
+
* Provides error code and HTTP status code for API responses
|
|
198
|
+
*/
|
|
199
|
+
export class HazoAuthError extends Error {
|
|
200
|
+
constructor(
|
|
201
|
+
message: string,
|
|
202
|
+
public readonly code: string,
|
|
203
|
+
public readonly status_code: number,
|
|
204
|
+
) {
|
|
205
|
+
super(message);
|
|
206
|
+
this.name = "HazoAuthError";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Error thrown when authentication is required but user is not authenticated
|
|
212
|
+
*/
|
|
213
|
+
export class AuthenticationRequiredError extends HazoAuthError {
|
|
214
|
+
constructor(message: string = "Authentication required") {
|
|
215
|
+
super(message, "AUTHENTICATION_REQUIRED", 401);
|
|
216
|
+
this.name = "AuthenticationRequiredError";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Error thrown when a tenant/scope context is required but not provided
|
|
222
|
+
*/
|
|
223
|
+
export class TenantRequiredError extends HazoAuthError {
|
|
224
|
+
constructor(
|
|
225
|
+
message: string = "Tenant context required",
|
|
226
|
+
public readonly user_scopes: ScopeDetails[] = [],
|
|
227
|
+
) {
|
|
228
|
+
super(message, "TENANT_REQUIRED", 403);
|
|
229
|
+
this.name = "TenantRequiredError";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Error thrown when user lacks access to the requested tenant/scope
|
|
235
|
+
*/
|
|
236
|
+
export class TenantAccessDeniedError extends HazoAuthError {
|
|
237
|
+
constructor(
|
|
238
|
+
public readonly scope_id: string,
|
|
239
|
+
public readonly user_scopes: ScopeDetails[] = [],
|
|
240
|
+
) {
|
|
241
|
+
super(`Access denied to scope: ${scope_id}`, "TENANT_ACCESS_DENIED", 403);
|
|
242
|
+
this.name = "TenantAccessDeniedError";
|
|
243
|
+
}
|
|
244
|
+
}
|