@happyvertical/smrt-users 0.30.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.
- package/AGENTS.md +85 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +459 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/chunks/TerminalAuthService-DoAMQ_yn.js +5118 -0
- package/dist/chunks/TerminalAuthService-DoAMQ_yn.js.map +1 -0
- package/dist/chunks/index-DkoYIvIu.js +169 -0
- package/dist/chunks/index-DkoYIvIu.js.map +1 -0
- package/dist/collections/CliAuthRequestCollection.d.ts +19 -0
- package/dist/collections/CliAuthRequestCollection.d.ts.map +1 -0
- package/dist/collections/GroupCollection.d.ts +17 -0
- package/dist/collections/GroupCollection.d.ts.map +1 -0
- package/dist/collections/GroupMemberCollection.d.ts +43 -0
- package/dist/collections/GroupMemberCollection.d.ts.map +1 -0
- package/dist/collections/GroupRoleCollection.d.ts +33 -0
- package/dist/collections/GroupRoleCollection.d.ts.map +1 -0
- package/dist/collections/MagicLinkTokenCollection.d.ts +26 -0
- package/dist/collections/MagicLinkTokenCollection.d.ts.map +1 -0
- package/dist/collections/MembershipCollection.d.ts +38 -0
- package/dist/collections/MembershipCollection.d.ts.map +1 -0
- package/dist/collections/MembershipOverrideCollection.d.ts +55 -0
- package/dist/collections/MembershipOverrideCollection.d.ts.map +1 -0
- package/dist/collections/PermissionCollection.d.ts +34 -0
- package/dist/collections/PermissionCollection.d.ts.map +1 -0
- package/dist/collections/RoleCollection.d.ts +29 -0
- package/dist/collections/RoleCollection.d.ts.map +1 -0
- package/dist/collections/RolePermissionCollection.d.ts +33 -0
- package/dist/collections/RolePermissionCollection.d.ts.map +1 -0
- package/dist/collections/SessionCollection.d.ts +82 -0
- package/dist/collections/SessionCollection.d.ts.map +1 -0
- package/dist/collections/TenantCollection.d.ts +119 -0
- package/dist/collections/TenantCollection.d.ts.map +1 -0
- package/dist/collections/TenantPermissionOverrideCollection.d.ts +111 -0
- package/dist/collections/TenantPermissionOverrideCollection.d.ts.map +1 -0
- package/dist/collections/UserCollection.d.ts +116 -0
- package/dist/collections/UserCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +19 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1482 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +5216 -0
- package/dist/models/CliAuthRequest.d.ts +25 -0
- package/dist/models/CliAuthRequest.d.ts.map +1 -0
- package/dist/models/Group.d.ts +34 -0
- package/dist/models/Group.d.ts.map +1 -0
- package/dist/models/GroupMember.d.ts +29 -0
- package/dist/models/GroupMember.d.ts.map +1 -0
- package/dist/models/GroupRole.d.ts +29 -0
- package/dist/models/GroupRole.d.ts.map +1 -0
- package/dist/models/MagicLinkToken.d.ts +22 -0
- package/dist/models/MagicLinkToken.d.ts.map +1 -0
- package/dist/models/Membership.d.ts +48 -0
- package/dist/models/Membership.d.ts.map +1 -0
- package/dist/models/MembershipOverride.d.ts +50 -0
- package/dist/models/MembershipOverride.d.ts.map +1 -0
- package/dist/models/Permission.d.ts +79 -0
- package/dist/models/Permission.d.ts.map +1 -0
- package/dist/models/Role.d.ts +67 -0
- package/dist/models/Role.d.ts.map +1 -0
- package/dist/models/RolePermission.d.ts +29 -0
- package/dist/models/RolePermission.d.ts.map +1 -0
- package/dist/models/Session.d.ts +105 -0
- package/dist/models/Session.d.ts.map +1 -0
- package/dist/models/Tenant.d.ts +138 -0
- package/dist/models/Tenant.d.ts.map +1 -0
- package/dist/models/TenantPermissionOverride.d.ts +74 -0
- package/dist/models/TenantPermissionOverride.d.ts.map +1 -0
- package/dist/models/User.d.ts +72 -0
- package/dist/models/User.d.ts.map +1 -0
- package/dist/models/index.d.ts +19 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/playground.d.ts +2 -0
- package/dist/playground.d.ts.map +1 -0
- package/dist/playground.js +139 -0
- package/dist/playground.js.map +1 -0
- package/dist/services/MagicLinkService.d.ts +84 -0
- package/dist/services/MagicLinkService.d.ts.map +1 -0
- package/dist/services/OidcLoginService.d.ts +134 -0
- package/dist/services/OidcLoginService.d.ts.map +1 -0
- package/dist/services/PermissionCatalogService.d.ts +62 -0
- package/dist/services/PermissionCatalogService.d.ts.map +1 -0
- package/dist/services/PermissionResolver.d.ts +150 -0
- package/dist/services/PermissionResolver.d.ts.map +1 -0
- package/dist/services/PostgresPermissionPolicies.d.ts +29 -0
- package/dist/services/PostgresPermissionPolicies.d.ts.map +1 -0
- package/dist/services/SessionPermissionContext.d.ts +43 -0
- package/dist/services/SessionPermissionContext.d.ts.map +1 -0
- package/dist/services/SessionService.d.ts +139 -0
- package/dist/services/SessionService.d.ts.map +1 -0
- package/dist/services/TenantService.d.ts +135 -0
- package/dist/services/TenantService.d.ts.map +1 -0
- package/dist/services/TerminalAuthService.d.ts +189 -0
- package/dist/services/TerminalAuthService.d.ts.map +1 -0
- package/dist/services/index.d.ts +14 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +2744 -0
- package/dist/svelte/components/InviteUserModal.svelte +351 -0
- package/dist/svelte/components/InviteUserModal.svelte.d.ts +17 -0
- package/dist/svelte/components/InviteUserModal.svelte.d.ts.map +1 -0
- package/dist/svelte/components/UserAvatar.svelte +105 -0
- package/dist/svelte/components/UserAvatar.svelte.d.ts +10 -0
- package/dist/svelte/components/UserAvatar.svelte.d.ts.map +1 -0
- package/dist/svelte/components/UserCard.svelte +179 -0
- package/dist/svelte/components/UserCard.svelte.d.ts +18 -0
- package/dist/svelte/components/UserCard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/UserForm.svelte +194 -0
- package/dist/svelte/components/UserForm.svelte.d.ts +18 -0
- package/dist/svelte/components/UserForm.svelte.d.ts.map +1 -0
- package/dist/svelte/components/UserList.svelte +107 -0
- package/dist/svelte/components/UserList.svelte.d.ts +20 -0
- package/dist/svelte/components/UserList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/UserMenu.svelte +326 -0
- package/dist/svelte/components/UserMenu.svelte.d.ts +33 -0
- package/dist/svelte/components/UserMenu.svelte.d.ts.map +1 -0
- package/dist/svelte/components/__tests__/InviteUserModal.test.js +54 -0
- package/dist/svelte/components/__tests__/UserAvatar.test.js +31 -0
- package/dist/svelte/components/__tests__/UserCard.test.js +39 -0
- package/dist/svelte/components/__tests__/UserForm.test.js +50 -0
- package/dist/svelte/components/__tests__/UserList.test.js +48 -0
- package/dist/svelte/components/__tests__/UserMenu.test.js +38 -0
- package/dist/svelte/i18n.d.ts +15 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +15 -0
- package/dist/svelte/index.d.ts +23 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +27 -0
- package/dist/svelte/playground.d.ts +151 -0
- package/dist/svelte/playground.d.ts.map +1 -0
- package/dist/svelte/playground.js +134 -0
- package/dist/sveltekit/index.d.ts +379 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/resource-list-handler.d.ts +127 -0
- package/dist/sveltekit/resource-list-handler.d.ts.map +1 -0
- package/dist/sveltekit/types.d.ts +31 -0
- package/dist/sveltekit/types.d.ts.map +1 -0
- package/dist/sveltekit.d.ts +2 -0
- package/dist/sveltekit.d.ts.map +1 -0
- package/dist/sveltekit.js +978 -0
- package/dist/sveltekit.js.map +1 -0
- package/dist/types/index.d.ts +61 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +75 -0
- package/dist/ui.js.map +1 -0
- package/package.json +97 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @happyvertical/smrt-users
|
|
2
|
+
|
|
3
|
+
Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.
|
|
4
|
+
|
|
5
|
+
## Models (13)
|
|
6
|
+
|
|
7
|
+
| Model | Key Pattern |
|
|
8
|
+
|-------|-------------|
|
|
9
|
+
| User | Auth identity. `profileId` is plain string (not FK) to smrt-profiles. Email auto-lowercased. |
|
|
10
|
+
| Tenant | **STI** + hierarchical parent-child. `hierarchyPath` (materialized path), `hierarchyLevel`. Max depth 10. |
|
|
11
|
+
| Session | Server-side. Secure UUID. TTL in **seconds** (not ms). Status auto-updates to EXPIRED on access. |
|
|
12
|
+
| MagicLinkToken | Single-use email login token. Backed by `MagicLinkService`. |
|
|
13
|
+
| Role | `tenantId = null` → system role (available to all tenants). `isSystem: true` blocks deletion. |
|
|
14
|
+
| Permission | Slug format: `resource.action`. Parsed by PermissionResolver. |
|
|
15
|
+
| Membership | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
|
|
16
|
+
| Group | Team within a tenant. Multiple roles via GroupRole. |
|
|
17
|
+
| GroupMember, GroupRole, RolePermission | Join tables. |
|
|
18
|
+
| MembershipOverride | Per-user permission grant/deny. **DENY always wins.** |
|
|
19
|
+
| TenantPermissionOverride | Tenant-level cascade overrides. Effect: INHERIT/GRANT/DENY. |
|
|
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)
|
|
29
|
+
|
|
30
|
+
**Critical**: `getGroupIdsForTenant(userId, tenantId)` (joins with groups table to scope by tenant). Never use `getGroupIds()` — it's cross-tenant.
|
|
31
|
+
|
|
32
|
+
## Hierarchical Tenants
|
|
33
|
+
|
|
34
|
+
- `TenantCollection.createChild()` auto-calculates hierarchy fields, enforces depth limit
|
|
35
|
+
- `moveToParent()` updates tenant + ALL descendants' paths/levels
|
|
36
|
+
- `cascadePermissions` (parent pushes down) + `inheritPermissions` (child accepts) — both must be true
|
|
37
|
+
- `getTree(rootId?)` returns nested structure for UI
|
|
38
|
+
|
|
39
|
+
## SvelteKit Integration
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// hooks.server.ts
|
|
43
|
+
export const handle = createSessionHandler({ db, ttl: 604800, skipPaths: ['/api/public'] });
|
|
44
|
+
// Populates event.locals: { user, membership, permissions: string[], tenantId, sessionId }
|
|
45
|
+
|
|
46
|
+
// +page.server.ts
|
|
47
|
+
await createSessionCookie(event, userId, tenantId, { db });
|
|
48
|
+
await destroySessionCookie(event, { db });
|
|
49
|
+
await switchSessionTenant(event, tenantId, { db });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Security (S5 #1400)
|
|
53
|
+
|
|
54
|
+
- **Generated REST/MCP surface is READ-ONLY for every RBAC/identity model.**
|
|
55
|
+
User, Tenant, Group, Membership, MembershipOverride, Role, Permission,
|
|
56
|
+
RolePermission, GroupRole, GroupMember, and TenantPermissionOverride generate
|
|
57
|
+
`list`/`get` only — `create`/`update`/`delete` are intentionally NOT
|
|
58
|
+
generated. The merged `requireRouteAuth` gate (#1540) enforces *authentication*,
|
|
59
|
+
not *authorization*, and these models are not `@TenantScoped`, so an
|
|
60
|
+
auto-generated mutating route would let any authenticated user self-grant a
|
|
61
|
+
role/permission, flip a tenant's cascade flags, or change another user's auth
|
|
62
|
+
identity. Mutate them through the permission-gated services (`TenantService`,
|
|
63
|
+
collection helpers) or consumer-owned, permission-checked handlers. A
|
|
64
|
+
structural regression test (`security-audit-1400.test.ts`) enumerates the
|
|
65
|
+
registry to assert no authority model exposes a mutating op. (`cli` stays
|
|
66
|
+
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.
|
|
74
|
+
- **OIDC `email_verified` is enforced.** `UserCollection.getOrCreateFromOidc`
|
|
75
|
+
refuses to provision a user when the IdP explicitly returns
|
|
76
|
+
`email_verified: false` (opt out with `{ allowUnverifiedEmail: true }`). An
|
|
77
|
+
absent claim makes no assertion and is not enforced.
|
|
78
|
+
|
|
79
|
+
## Gotchas
|
|
80
|
+
|
|
81
|
+
- **seedSystemRoles() required**: call `RoleCollection.seedSystemRoles()` at app init (creates owner/admin/member/viewer)
|
|
82
|
+
- **PermissionResolver casts `as any`**: collections have protected constructors — known framework limitation
|
|
83
|
+
- **Session TTL in seconds**: `DEFAULT_SESSION_TTL = 7 * 24 * 60 * 60` (not milliseconds)
|
|
84
|
+
- **Users are cross-tenant**: one user, many tenants via Membership. Email globally unique.
|
|
85
|
+
- **Batch permission queries**: resolver fetches all permission IDs in one query, then maps to slugs (avoids N+1)
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# @happyvertical/smrt-users
|
|
2
|
+
|
|
3
|
+
Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @happyvertical/smrt-users
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Roles and permissions
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import {
|
|
17
|
+
RoleCollection, MembershipCollection, PermissionResolver,
|
|
18
|
+
} from '@happyvertical/smrt-users';
|
|
19
|
+
|
|
20
|
+
const db = { db: { type: 'sqlite', url: 'app.db' } };
|
|
21
|
+
|
|
22
|
+
// Seed system roles (owner, admin, member, viewer) — required at app init
|
|
23
|
+
const roles = await RoleCollection.create(db);
|
|
24
|
+
await roles.seedSystemRoles();
|
|
25
|
+
|
|
26
|
+
// Assign a user to a tenant with the admin role
|
|
27
|
+
const memberships = await MembershipCollection.create(db);
|
|
28
|
+
const adminRole = await roles.findBySlug('admin');
|
|
29
|
+
await (await memberships.create({
|
|
30
|
+
userId: user.id, tenantId: tenant.id, roleId: adminRole.id,
|
|
31
|
+
})).save();
|
|
32
|
+
|
|
33
|
+
// Check permissions
|
|
34
|
+
const resolver = await PermissionResolver.create(db);
|
|
35
|
+
await resolver.hasPermission(user.id, tenant.id, 'articles.create');
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Manifest-derived permission catalog
|
|
39
|
+
|
|
40
|
+
SMRT objects now contribute permissions automatically based on their public
|
|
41
|
+
surface area.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { SmrtObject, smrt } from '@happyvertical/smrt-core';
|
|
45
|
+
|
|
46
|
+
@smrt({
|
|
47
|
+
api: { include: ['list', 'create', 'publish'] },
|
|
48
|
+
cli: { include: ['get', 'archive'] },
|
|
49
|
+
collection: 'articles',
|
|
50
|
+
mcp: { include: ['update'] },
|
|
51
|
+
tenantScoped: { mode: 'required' },
|
|
52
|
+
})
|
|
53
|
+
class Article extends SmrtObject {
|
|
54
|
+
tenantId: string = '';
|
|
55
|
+
title: string = '';
|
|
56
|
+
|
|
57
|
+
async publish(): Promise<boolean> {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async archive(): Promise<boolean> {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This produces the following permission slugs:
|
|
68
|
+
|
|
69
|
+
- `articles.read` from `list` or `get`
|
|
70
|
+
- `articles.create`
|
|
71
|
+
- `articles.update`
|
|
72
|
+
- `articles.publish`
|
|
73
|
+
- `articles.archive`
|
|
74
|
+
|
|
75
|
+
Non-public methods and actions that are not exposed through API, CLI, or MCP are
|
|
76
|
+
not added to the catalog.
|
|
77
|
+
|
|
78
|
+
### Sync the permission catalog
|
|
79
|
+
|
|
80
|
+
Use `syncPermissionCatalog()` during bootstrapping, migrations, or deploy hooks
|
|
81
|
+
to upsert discovered permissions into the `Permission` table.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { syncPermissionCatalog } from '@happyvertical/smrt-users';
|
|
85
|
+
|
|
86
|
+
const db = {
|
|
87
|
+
db: {
|
|
88
|
+
type: 'postgres' as const,
|
|
89
|
+
url: process.env.DATABASE_URL!,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const result = await syncPermissionCatalog(db);
|
|
94
|
+
|
|
95
|
+
console.log('created', result.created);
|
|
96
|
+
console.log('updated', result.updated);
|
|
97
|
+
console.log('unchanged', result.unchanged);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Catalog sync is additive and fail-closed:
|
|
101
|
+
|
|
102
|
+
- it creates missing `Permission` rows
|
|
103
|
+
- it updates `name`, `description`, and `category` by slug
|
|
104
|
+
- it does not auto-grant permissions to roles
|
|
105
|
+
- it does not delete stale permissions in v1
|
|
106
|
+
|
|
107
|
+
### App-defined permissions in `smrt.config.ts`
|
|
108
|
+
|
|
109
|
+
Use package config for permissions that do not come from the manifest.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// smrt.config.ts
|
|
113
|
+
import { defineConfig } from '@happyvertical/smrt-config';
|
|
114
|
+
|
|
115
|
+
export default defineConfig({
|
|
116
|
+
packages: {
|
|
117
|
+
users: {
|
|
118
|
+
permissions: {
|
|
119
|
+
custom: [
|
|
120
|
+
{
|
|
121
|
+
category: 'app',
|
|
122
|
+
description: 'Allows access to the operations dashboard',
|
|
123
|
+
name: 'View Operations Dashboard',
|
|
124
|
+
slug: 'operations.dashboard',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
category: 'audits',
|
|
128
|
+
name: 'Inspect Audit Rows',
|
|
129
|
+
postgres: {
|
|
130
|
+
bindings: [
|
|
131
|
+
{
|
|
132
|
+
action: 'select',
|
|
133
|
+
tableName: 'audit_logs',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
action: 'insert',
|
|
137
|
+
tableName: 'audit_logs',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
slug: 'audits.inspect',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
postgres: {
|
|
145
|
+
enabled: true,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Custom permissions merge with manifest-derived permissions by slug. If the same
|
|
154
|
+
slug is registered with conflicting metadata, SMRT throws so the mismatch is
|
|
155
|
+
visible early.
|
|
156
|
+
|
|
157
|
+
### Runtime permission registration
|
|
158
|
+
|
|
159
|
+
Use `registerPermissionDefinitions()` when a package or integration needs to
|
|
160
|
+
declare permissions at runtime.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import {
|
|
164
|
+
registerPermissionDefinitions,
|
|
165
|
+
syncPermissionCatalog,
|
|
166
|
+
} from '@happyvertical/smrt-users';
|
|
167
|
+
|
|
168
|
+
const unregister = registerPermissionDefinitions([
|
|
169
|
+
{
|
|
170
|
+
category: 'billing',
|
|
171
|
+
description: 'Allows exporting invoices',
|
|
172
|
+
name: 'Export Invoices',
|
|
173
|
+
slug: 'invoices.export',
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await syncPermissionCatalog({
|
|
179
|
+
db: { type: 'sqlite', url: 'app.db' },
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
unregister();
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Postgres RLS enforcement
|
|
187
|
+
|
|
188
|
+
For Postgres, SMRT can generate and apply row-level security policies directly
|
|
189
|
+
from the permission catalog.
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
import {
|
|
193
|
+
applyPostgresPermissionPolicies,
|
|
194
|
+
generatePostgresPermissionSql,
|
|
195
|
+
syncPermissionCatalog,
|
|
196
|
+
} from '@happyvertical/smrt-users';
|
|
197
|
+
|
|
198
|
+
const db = {
|
|
199
|
+
db: {
|
|
200
|
+
type: 'postgres' as const,
|
|
201
|
+
url: process.env.DATABASE_URL!,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
await syncPermissionCatalog(db);
|
|
206
|
+
|
|
207
|
+
const preview = generatePostgresPermissionSql(db);
|
|
208
|
+
console.log(preview.targets);
|
|
209
|
+
console.log(preview.skipped);
|
|
210
|
+
|
|
211
|
+
await applyPostgresPermissionPolicies(db);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Automatic policy generation currently applies only to objects that are:
|
|
215
|
+
|
|
216
|
+
- tenant-scoped with `tenantScoped: { mode: 'required' }`
|
|
217
|
+
- backed by a real Postgres table
|
|
218
|
+
- mapped to a single tenant field
|
|
219
|
+
|
|
220
|
+
Automatic CRUD policy mapping is fixed in v1:
|
|
221
|
+
|
|
222
|
+
- `SELECT` -> `<collection>.read`
|
|
223
|
+
- `INSERT` -> `<collection>.create`
|
|
224
|
+
- `UPDATE` -> `<collection>.update`
|
|
225
|
+
- `DELETE` -> `<collection>.delete`
|
|
226
|
+
|
|
227
|
+
Optional-tenancy and global tables are skipped and returned in
|
|
228
|
+
`result.skipped` instead of generating unsafe policies. Custom permissions can
|
|
229
|
+
participate in RLS by adding explicit Postgres bindings as shown above.
|
|
230
|
+
|
|
231
|
+
### SvelteKit hooks
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// hooks.server.ts
|
|
235
|
+
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
236
|
+
|
|
237
|
+
export const handle = createSessionHandler({
|
|
238
|
+
db: { type: 'postgres', url: process.env.DATABASE_URL },
|
|
239
|
+
enterTenantContext: true,
|
|
240
|
+
postgresRls: true,
|
|
241
|
+
ttl: 7 * 24 * 60 * 60, // 7 days in seconds
|
|
242
|
+
skipPaths: ['/api/health'],
|
|
243
|
+
});
|
|
244
|
+
// Populates event.locals: { user, permissions, tenantId, sessionId }
|
|
245
|
+
|
|
246
|
+
// +page.server.ts
|
|
247
|
+
import { createSessionCookie, destroySessionCookie } from '@happyvertical/smrt-users/sveltekit';
|
|
248
|
+
|
|
249
|
+
await createSessionCookie(event, userId, tenantId, { db }); // login
|
|
250
|
+
await destroySessionCookie(event, { db }); // logout
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### OIDC login with Kanidm or Dex
|
|
254
|
+
|
|
255
|
+
Kanidm and Dex both work through the generic SMRT OIDC flow. Configure one or
|
|
256
|
+
more providers under `packages.users.auth.oidc.providers`, then add login and
|
|
257
|
+
callback route handlers.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// smrt.config.ts
|
|
261
|
+
import { defineConfig } from '@happyvertical/smrt-config';
|
|
262
|
+
|
|
263
|
+
export default defineConfig({
|
|
264
|
+
packages: {
|
|
265
|
+
users: {
|
|
266
|
+
auth: {
|
|
267
|
+
oidc: {
|
|
268
|
+
defaultProvider: 'kanidm',
|
|
269
|
+
providers: {
|
|
270
|
+
kanidm: {
|
|
271
|
+
kind: 'kanidm',
|
|
272
|
+
issuer: process.env.KANIDM_ISSUER!,
|
|
273
|
+
clientId: process.env.KANIDM_CLIENT_ID!,
|
|
274
|
+
clientSecret: process.env.KANIDM_CLIENT_SECRET,
|
|
275
|
+
redirectUri: 'http://localhost:5173/auth/kanidm/callback',
|
|
276
|
+
},
|
|
277
|
+
dex: {
|
|
278
|
+
kind: 'dex',
|
|
279
|
+
issuer: process.env.DEX_ISSUER!,
|
|
280
|
+
clientId: process.env.DEX_CLIENT_ID!,
|
|
281
|
+
clientSecret: process.env.DEX_CLIENT_SECRET,
|
|
282
|
+
redirectUri: 'http://localhost:5173/auth/dex/callback',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// src/routes/auth/[provider]/login/+server.ts
|
|
294
|
+
import { createOidcLoginHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
295
|
+
|
|
296
|
+
export const GET = createOidcLoginHandler({
|
|
297
|
+
db: { type: 'postgres', url: process.env.DATABASE_URL! },
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// src/routes/auth/[provider]/callback/+server.ts
|
|
303
|
+
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
304
|
+
|
|
305
|
+
export const GET = createOidcCallbackHandler({
|
|
306
|
+
db: { type: 'postgres', url: process.env.DATABASE_URL! },
|
|
307
|
+
successRedirect: '/dashboard',
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The callback verifies `state`, PKCE, issuer, audience, nonce, and the provider
|
|
312
|
+
JWKS-signed ID token, falling back to the OIDC UserInfo endpoint when the ID
|
|
313
|
+
token omits required profile claims like `email`. Temporary transaction cookies
|
|
314
|
+
are HMAC-signed with the provider `clientSecret` when present; public clients
|
|
315
|
+
can pass `transactionCookieSecret` to the route helpers. On success it creates
|
|
316
|
+
or reuses a SMRT `Profile`, links an `OidcIdentity`, creates or reuses a `User`,
|
|
317
|
+
records `lastLoginAt`, and sets the standard SMRT session cookie.
|
|
318
|
+
|
|
319
|
+
With `postgresRls: true`, SMRT opens a request-scoped Postgres transaction,
|
|
320
|
+
loads the session, resolves permissions, and sets session variables used by the
|
|
321
|
+
generated RLS helpers:
|
|
322
|
+
|
|
323
|
+
- `smrt.tenant_id`
|
|
324
|
+
- `smrt.user_id`
|
|
325
|
+
- `smrt.session_id`
|
|
326
|
+
- `smrt.permissions`
|
|
327
|
+
- `smrt.super_admin_bypass`
|
|
328
|
+
- `smrt.system_context`
|
|
329
|
+
|
|
330
|
+
With `enterTenantContext: true`, the same request also enters
|
|
331
|
+
`@happyvertical/smrt-tenancy` context so regular collection access is scoped to
|
|
332
|
+
the current tenant in application code.
|
|
333
|
+
|
|
334
|
+
### Request-scoped database access
|
|
335
|
+
|
|
336
|
+
Generated SvelteKit helpers and custom server code can read the current
|
|
337
|
+
request-scoped database, which is especially useful when Postgres RLS is enabled
|
|
338
|
+
and you want collection operations to use the active transaction.
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import {
|
|
342
|
+
getRequestScopedDatabase,
|
|
343
|
+
withSessionPermissionContext,
|
|
344
|
+
} from '@happyvertical/smrt-users';
|
|
345
|
+
|
|
346
|
+
const response = await withSessionPermissionContext(
|
|
347
|
+
{
|
|
348
|
+
db: { type: 'postgres', url: process.env.DATABASE_URL! },
|
|
349
|
+
enterTenantContext: true,
|
|
350
|
+
postgresRls: true,
|
|
351
|
+
sessionId,
|
|
352
|
+
},
|
|
353
|
+
async (context) => {
|
|
354
|
+
const database = getRequestScopedDatabase();
|
|
355
|
+
|
|
356
|
+
console.log(context.permissions);
|
|
357
|
+
console.log(database === context.database); // true
|
|
358
|
+
|
|
359
|
+
return new Response('ok');
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Key Concepts
|
|
365
|
+
|
|
366
|
+
### Permission cascade (4 levels)
|
|
367
|
+
|
|
368
|
+
PermissionResolver evaluates permissions in order, where each level can add or remove grants:
|
|
369
|
+
|
|
370
|
+
1. **Tenant hierarchy** -- walk ancestors, apply TenantPermissionOverride at each level
|
|
371
|
+
2. **Membership role** -- base permissions from the user's role in the tenant
|
|
372
|
+
3. **Group roles** -- permissions from all groups the user belongs to in that tenant
|
|
373
|
+
4. **Membership overrides** -- per-user GRANT/DENY (DENY always wins)
|
|
374
|
+
|
|
375
|
+
Tenant-level inherited permissions are part of the effective permission set
|
|
376
|
+
returned by `resolvePermissions()` and `SessionService.loadSessionContext()`.
|
|
377
|
+
|
|
378
|
+
### Hierarchical tenants
|
|
379
|
+
|
|
380
|
+
Tenants support parent-child trees (max depth 10). Two flags control inheritance: `cascadePermissions` (parent pushes down) and `inheritPermissions` (child accepts). Both must be true for permissions to flow.
|
|
381
|
+
|
|
382
|
+
### Tenant policies
|
|
383
|
+
|
|
384
|
+
TenantService supports three modes: `flexible` (no auto-create), `personal` (auto-create on first login, deletable), `required` (auto-create, must keep at least one).
|
|
385
|
+
|
|
386
|
+
## API
|
|
387
|
+
|
|
388
|
+
### Models
|
|
389
|
+
|
|
390
|
+
| Export | Description |
|
|
391
|
+
|--------|-------------|
|
|
392
|
+
| `User` | Auth identity. Email auto-lowercased. `profileId` links to smrt-profiles (plain string). |
|
|
393
|
+
| `Tenant` | Organizational boundary. STI. Hierarchical via `parentTenantId`/`hierarchyPath`. |
|
|
394
|
+
| `Role` | Permission template. `tenantId = null` for system roles. `isSystem` blocks deletion. |
|
|
395
|
+
| `Permission` | Named capability. Slug format: `resource.action`. |
|
|
396
|
+
| `Session` | Server-side session. Secure UUID. TTL in seconds. |
|
|
397
|
+
| `Group` | Team within a tenant. Gains permissions via GroupRole. |
|
|
398
|
+
| `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
|
|
399
|
+
| `MembershipOverride` | Per-user permission grant/deny on a membership. |
|
|
400
|
+
| `TenantPermissionOverride` | Tenant-level permission override (INHERIT/GRANT/DENY). |
|
|
401
|
+
| `GroupMember`, `GroupRole`, `RolePermission` | Junction tables for groups and role-permission assignments. |
|
|
402
|
+
|
|
403
|
+
### Collections
|
|
404
|
+
|
|
405
|
+
| Export | Description |
|
|
406
|
+
|--------|-------------|
|
|
407
|
+
| `UserCollection`, `TenantCollection`, `RoleCollection` | Core CRUD. TenantCollection adds `createChild()`, `getTree()`. RoleCollection adds `seedSystemRoles()`. |
|
|
408
|
+
| `PermissionCollection`, `SessionCollection` | Permission CRUD with `findByIds()`. Session CRUD with `findValidSession()`, `deleteExpired()`. |
|
|
409
|
+
| `MembershipCollection` | Membership CRUD, `findByUserAndTenant()` |
|
|
410
|
+
| `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
|
|
411
|
+
| `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
|
|
412
|
+
|
|
413
|
+
### Services
|
|
414
|
+
|
|
415
|
+
| Export | Description |
|
|
416
|
+
|--------|-------------|
|
|
417
|
+
| `PermissionResolver` | Resolves effective permissions via 4-level cascade. `hasPermission()`, `resolvePermissions()`. |
|
|
418
|
+
| `PermissionCatalogService`, `syncPermissionCatalog()` | Discovers manifest/config/runtime permissions and upserts them into `Permission` rows. |
|
|
419
|
+
| `registerPermissionDefinitions()` | Register app or integration permissions at runtime and receive an unregister cleanup function. |
|
|
420
|
+
| `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |
|
|
421
|
+
| `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`. |
|
|
422
|
+
| `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
|
|
423
|
+
| `withSessionPermissionContext()` | Loads a session, optionally enters tenancy context, and exposes a request-scoped database/permission context. |
|
|
424
|
+
| `getCurrentSessionPermissionContext()`, `getRequestScopedDatabase()` | Read the active request/session context inside app code. |
|
|
425
|
+
| `TenantService` | Policy-driven tenant lifecycle. `ensureTenantForUser()`, `createTenantWithOwnership()`. |
|
|
426
|
+
|
|
427
|
+
### SvelteKit (`@happyvertical/smrt-users/sveltekit`)
|
|
428
|
+
|
|
429
|
+
| Export | Description |
|
|
430
|
+
|--------|-------------|
|
|
431
|
+
| `createSessionHandler` | SvelteKit handle hook that populates `event.locals`, and can also enter tenancy context and Postgres RLS request transactions |
|
|
432
|
+
| `createSessionCookie` | Set session cookie after login |
|
|
433
|
+
| `destroySessionCookie` | Clear session cookie on logout |
|
|
434
|
+
| `switchSessionTenant` | Change tenant context for current session |
|
|
435
|
+
| `beginOidcLogin`, `completeOidcLogin` | Low-level SvelteKit helpers for custom OIDC login routes |
|
|
436
|
+
| `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
|
|
437
|
+
| `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |
|
|
438
|
+
|
|
439
|
+
### Types & Constants
|
|
440
|
+
|
|
441
|
+
| Export | Description |
|
|
442
|
+
|--------|-------------|
|
|
443
|
+
| `UserStatus`, `TenantStatus`, `SessionStatus`, `MembershipStatus` | Status enums |
|
|
444
|
+
| `OverrideEffect`, `TenantPermissionEffect` | Override effect enums |
|
|
445
|
+
| `DEFAULT_ROLE_SLUGS`, `DEFAULT_ROLES`, `DEFAULT_TENANT_POLICY` | System role slugs, role configs, default tenant policy |
|
|
446
|
+
| `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
|
|
447
|
+
| `TenantHierarchyError` | Thrown when hierarchy depth limit is exceeded |
|
|
448
|
+
|
|
449
|
+
## Dependencies
|
|
450
|
+
|
|
451
|
+
- `@happyvertical/smrt-core` -- ORM, `@smrt()` decorator, SmrtObject/SmrtCollection
|
|
452
|
+
- `@happyvertical/smrt-types` -- shared enums (UserStatus, SessionStatus, etc.)
|
|
453
|
+
- `@happyvertical/smrt-profiles` -- optional peer dependency for profile linking
|
|
454
|
+
- `jose` -- JWT/JWKS verification for OIDC and magic-link tokens
|
|
455
|
+
- `svelte` -- optional peer dependency for Svelte components
|
|
456
|
+
|
|
457
|
+
## License
|
|
458
|
+
|
|
459
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
|