@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13
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 +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { DuplicateLinkedIdentityError, type LinkedIdentityStore } from './linked-identity-store'
|
|
3
|
+
import type { LinkedIdentity, OAuthState, OAuthStateStore } from './oauth-types'
|
|
4
|
+
|
|
5
|
+
interface SqliteDatabase {
|
|
6
|
+
pragma(source: string): unknown
|
|
7
|
+
exec(source: string): void
|
|
8
|
+
prepare(source: string): {
|
|
9
|
+
run(...params: unknown[]): { changes?: number } | unknown
|
|
10
|
+
get(...params: unknown[]): unknown
|
|
11
|
+
all(...params: unknown[]): unknown[]
|
|
12
|
+
}
|
|
13
|
+
transaction<T>(fn: () => T): () => T
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OAuthStateRow {
|
|
17
|
+
state: string
|
|
18
|
+
provider: string
|
|
19
|
+
redirect_uri: string
|
|
20
|
+
created_at: number
|
|
21
|
+
expires_at: number
|
|
22
|
+
metadata_json: string | null
|
|
23
|
+
code_verifier: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LinkedIdentityRow {
|
|
27
|
+
id: string
|
|
28
|
+
user_id: string
|
|
29
|
+
provider: string
|
|
30
|
+
provider_user_id: string
|
|
31
|
+
email: string | null
|
|
32
|
+
linked_at: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SqliteOAuthStateStore implements OAuthStateStore {
|
|
36
|
+
private readonly db: SqliteDatabase
|
|
37
|
+
|
|
38
|
+
constructor(db: SqliteDatabase) {
|
|
39
|
+
this.db = db
|
|
40
|
+
this.db.pragma('journal_mode = WAL')
|
|
41
|
+
this.ensureTables()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async store(state: OAuthState): Promise<void> {
|
|
45
|
+
this.db
|
|
46
|
+
.prepare(`
|
|
47
|
+
INSERT OR REPLACE INTO auth_oauth_states
|
|
48
|
+
(state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
50
|
+
`)
|
|
51
|
+
.run(
|
|
52
|
+
state.state,
|
|
53
|
+
state.provider,
|
|
54
|
+
state.redirectUri,
|
|
55
|
+
state.createdAt,
|
|
56
|
+
state.expiresAt,
|
|
57
|
+
state.metadata ? JSON.stringify(state.metadata) : null,
|
|
58
|
+
state.codeVerifier ?? null,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async consume(stateValue: string): Promise<OAuthState | null> {
|
|
63
|
+
const consumeInTransaction = this.db.transaction(() => {
|
|
64
|
+
const row = this.db
|
|
65
|
+
.prepare('SELECT * FROM auth_oauth_states WHERE state = ?')
|
|
66
|
+
.get(stateValue) as OAuthStateRow | undefined
|
|
67
|
+
|
|
68
|
+
if (!row) return null
|
|
69
|
+
|
|
70
|
+
this.db.prepare('DELETE FROM auth_oauth_states WHERE state = ?').run(stateValue)
|
|
71
|
+
if (Date.now() > row.expires_at) return null
|
|
72
|
+
|
|
73
|
+
return rowToOAuthState(row)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return consumeInTransaction()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async cleanExpired(): Promise<number> {
|
|
80
|
+
const result = this.db
|
|
81
|
+
.prepare('DELETE FROM auth_oauth_states WHERE expires_at < ?')
|
|
82
|
+
.run(Date.now()) as { changes?: number }
|
|
83
|
+
return result.changes ?? 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private ensureTables(): void {
|
|
87
|
+
this.db.exec(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS auth_oauth_states (
|
|
89
|
+
state TEXT PRIMARY KEY,
|
|
90
|
+
provider TEXT NOT NULL,
|
|
91
|
+
redirect_uri TEXT NOT NULL,
|
|
92
|
+
created_at INTEGER NOT NULL,
|
|
93
|
+
expires_at INTEGER NOT NULL,
|
|
94
|
+
metadata_json TEXT,
|
|
95
|
+
code_verifier TEXT
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
|
|
99
|
+
ON auth_oauth_states(expires_at);
|
|
100
|
+
`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class SqliteLinkedIdentityStore implements LinkedIdentityStore {
|
|
105
|
+
private readonly db: SqliteDatabase
|
|
106
|
+
|
|
107
|
+
constructor(db: SqliteDatabase) {
|
|
108
|
+
this.db = db
|
|
109
|
+
this.db.pragma('journal_mode = WAL')
|
|
110
|
+
this.ensureTables()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
|
|
114
|
+
const row = this.db
|
|
115
|
+
.prepare(`
|
|
116
|
+
SELECT * FROM auth_linked_identities
|
|
117
|
+
WHERE provider = ? AND provider_user_id = ?
|
|
118
|
+
`)
|
|
119
|
+
.get(provider, providerUserId) as LinkedIdentityRow | undefined
|
|
120
|
+
|
|
121
|
+
return row ? rowToLinkedIdentity(row) : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async findByUser(userId: string): Promise<LinkedIdentity[]> {
|
|
125
|
+
const rows = this.db
|
|
126
|
+
.prepare(`
|
|
127
|
+
SELECT * FROM auth_linked_identities
|
|
128
|
+
WHERE user_id = ?
|
|
129
|
+
ORDER BY linked_at ASC
|
|
130
|
+
`)
|
|
131
|
+
.all(userId) as LinkedIdentityRow[]
|
|
132
|
+
|
|
133
|
+
return rows.map(rowToLinkedIdentity)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async create(params: {
|
|
137
|
+
userId: string
|
|
138
|
+
provider: string
|
|
139
|
+
providerUserId: string
|
|
140
|
+
email: string | null
|
|
141
|
+
}): Promise<LinkedIdentity> {
|
|
142
|
+
const identity: LinkedIdentity = {
|
|
143
|
+
id: randomUUID(),
|
|
144
|
+
userId: params.userId,
|
|
145
|
+
provider: params.provider,
|
|
146
|
+
providerUserId: params.providerUserId,
|
|
147
|
+
email: params.email,
|
|
148
|
+
linkedAt: Date.now(),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
this.db
|
|
153
|
+
.prepare(`
|
|
154
|
+
INSERT INTO auth_linked_identities
|
|
155
|
+
(id, user_id, provider, provider_user_id, email, linked_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
157
|
+
`)
|
|
158
|
+
.run(
|
|
159
|
+
identity.id,
|
|
160
|
+
identity.userId,
|
|
161
|
+
identity.provider,
|
|
162
|
+
identity.providerUserId,
|
|
163
|
+
identity.email,
|
|
164
|
+
identity.linkedAt,
|
|
165
|
+
)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
|
168
|
+
throw new DuplicateLinkedIdentityError(params.provider)
|
|
169
|
+
}
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return identity
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async delete(userId: string, provider: string): Promise<void> {
|
|
177
|
+
this.db
|
|
178
|
+
.prepare('DELETE FROM auth_linked_identities WHERE user_id = ? AND provider = ?')
|
|
179
|
+
.run(userId, provider)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private ensureTables(): void {
|
|
183
|
+
this.db.exec(`
|
|
184
|
+
CREATE TABLE IF NOT EXISTS auth_linked_identities (
|
|
185
|
+
id TEXT PRIMARY KEY,
|
|
186
|
+
user_id TEXT NOT NULL,
|
|
187
|
+
provider TEXT NOT NULL,
|
|
188
|
+
provider_user_id TEXT NOT NULL,
|
|
189
|
+
email TEXT,
|
|
190
|
+
linked_at INTEGER NOT NULL,
|
|
191
|
+
UNIQUE(provider, provider_user_id),
|
|
192
|
+
UNIQUE(user_id, provider)
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
|
|
196
|
+
ON auth_linked_identities(user_id);
|
|
197
|
+
`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function createSqliteOAuthStateStore(options: {
|
|
202
|
+
filename: string
|
|
203
|
+
}): Promise<SqliteOAuthStateStore> {
|
|
204
|
+
const Database = await loadBetterSqlite3()
|
|
205
|
+
const db = new Database(options.filename)
|
|
206
|
+
return new SqliteOAuthStateStore(db as unknown as SqliteDatabase)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function createSqliteLinkedIdentityStore(options: {
|
|
210
|
+
filename: string
|
|
211
|
+
}): Promise<SqliteLinkedIdentityStore> {
|
|
212
|
+
const Database = await loadBetterSqlite3()
|
|
213
|
+
const db = new Database(options.filename)
|
|
214
|
+
return new SqliteLinkedIdentityStore(db as unknown as SqliteDatabase)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function createSqliteOAuthStores(options: { filename: string }): Promise<{
|
|
218
|
+
stateStore: SqliteOAuthStateStore
|
|
219
|
+
linkedIdentityStore: SqliteLinkedIdentityStore
|
|
220
|
+
}> {
|
|
221
|
+
const Database = await loadBetterSqlite3()
|
|
222
|
+
const db = new Database(options.filename) as unknown as SqliteDatabase
|
|
223
|
+
return {
|
|
224
|
+
stateStore: new SqliteOAuthStateStore(db),
|
|
225
|
+
linkedIdentityStore: new SqliteLinkedIdentityStore(db),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function loadBetterSqlite3(): Promise<new (filename: string) => unknown> {
|
|
230
|
+
try {
|
|
231
|
+
const { createRequire } = await import('node:module')
|
|
232
|
+
const require = createRequire(import.meta.url)
|
|
233
|
+
return require('better-sqlite3') as new (
|
|
234
|
+
filename: string,
|
|
235
|
+
) => unknown
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error(
|
|
238
|
+
'SQLite OAuth stores require the "better-sqlite3" package. Install it in your project dependencies.',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function rowToOAuthState(row: OAuthStateRow): OAuthState {
|
|
244
|
+
return {
|
|
245
|
+
state: row.state,
|
|
246
|
+
provider: row.provider,
|
|
247
|
+
redirectUri: row.redirect_uri,
|
|
248
|
+
createdAt: row.created_at,
|
|
249
|
+
expiresAt: row.expires_at,
|
|
250
|
+
metadata: parseMetadata(row.metadata_json),
|
|
251
|
+
codeVerifier: row.code_verifier ?? undefined,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseMetadata(value: string | null): Record<string, unknown> | undefined {
|
|
256
|
+
if (!value) return undefined
|
|
257
|
+
const parsed = JSON.parse(value) as unknown
|
|
258
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
259
|
+
? (parsed as Record<string, unknown>)
|
|
260
|
+
: undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function rowToLinkedIdentity(row: LinkedIdentityRow): LinkedIdentity {
|
|
264
|
+
return {
|
|
265
|
+
id: row.id,
|
|
266
|
+
userId: row.user_id,
|
|
267
|
+
provider: row.provider,
|
|
268
|
+
providerUserId: row.provider_user_id,
|
|
269
|
+
email: row.email,
|
|
270
|
+
linkedAt: row.linked_at,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { OrgStore } from '../org/org-store'
|
|
2
|
+
import type {
|
|
3
|
+
CollectionScopeResolver,
|
|
4
|
+
Permission,
|
|
5
|
+
RbacConfig,
|
|
6
|
+
RoleDefinition,
|
|
7
|
+
ScopeContext,
|
|
8
|
+
ScopeFilter,
|
|
9
|
+
SyncScopes,
|
|
10
|
+
} from './rbac-types'
|
|
11
|
+
import {
|
|
12
|
+
BUILT_IN_ROLES,
|
|
13
|
+
CircularInheritanceError,
|
|
14
|
+
RoleNotFoundError,
|
|
15
|
+
permissionCovers,
|
|
16
|
+
} from './rbac-types'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// RbacEngine
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Permission evaluation engine for role-based access control.
|
|
24
|
+
*
|
|
25
|
+
* The engine resolves permissions through role inheritance, supports
|
|
26
|
+
* wildcard matching, and integrates with the OrgStore for membership lookups.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const rbac = new RbacEngine({ orgStore })
|
|
31
|
+
*
|
|
32
|
+
* // Check a permission
|
|
33
|
+
* const canWrite = await rbac.hasPermission('user-1', 'org-1', 'todos:write')
|
|
34
|
+
*
|
|
35
|
+
* // Get all permissions for a user in an org
|
|
36
|
+
* const perms = await rbac.getUserPermissions('user-1', 'org-1')
|
|
37
|
+
*
|
|
38
|
+
* // Resolve sync scopes
|
|
39
|
+
* const scopes = await rbac.resolveScopes('user-1', 'org-1')
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class RbacEngine {
|
|
43
|
+
private readonly orgStore: OrgStore
|
|
44
|
+
private readonly roleMap: Map<string, RoleDefinition>
|
|
45
|
+
private readonly resolvedPermissions = new Map<string, Permission[]>()
|
|
46
|
+
private readonly collectionResolvers = new Map<string, CollectionScopeResolver>()
|
|
47
|
+
|
|
48
|
+
constructor(orgStore: OrgStore, config?: RbacConfig) {
|
|
49
|
+
this.orgStore = orgStore
|
|
50
|
+
|
|
51
|
+
const roles = config?.roles ?? [...BUILT_IN_ROLES]
|
|
52
|
+
this.roleMap = new Map()
|
|
53
|
+
for (const role of roles) {
|
|
54
|
+
this.roleMap.set(role.name, role)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate and pre-resolve all role permissions
|
|
58
|
+
this.validateRoles()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Permission Checks ---
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a user has a specific permission in an organization.
|
|
65
|
+
*
|
|
66
|
+
* Resolves the user's role from the org membership, then evaluates
|
|
67
|
+
* the role's permissions (including inherited ones) against the required permission.
|
|
68
|
+
*
|
|
69
|
+
* Returns false (not an error) if the user is not a member.
|
|
70
|
+
*/
|
|
71
|
+
async hasPermission(userId: string, orgId: string, permission: Permission): Promise<boolean> {
|
|
72
|
+
const membership = await this.orgStore.getMembership(orgId, userId)
|
|
73
|
+
if (!membership) return false
|
|
74
|
+
|
|
75
|
+
const rolePerms = this.getRolePermissions(membership.role)
|
|
76
|
+
return rolePerms.some((granted) => permissionCovers(granted, permission))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get all effective permissions for a user in an organization.
|
|
81
|
+
*
|
|
82
|
+
* Returns an empty array if the user is not a member.
|
|
83
|
+
*/
|
|
84
|
+
async getUserPermissions(userId: string, orgId: string): Promise<Permission[]> {
|
|
85
|
+
const membership = await this.orgStore.getMembership(orgId, userId)
|
|
86
|
+
if (!membership) return []
|
|
87
|
+
|
|
88
|
+
return this.getRolePermissions(membership.role)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get all effective permissions for a role name.
|
|
93
|
+
* Includes permissions from inherited roles.
|
|
94
|
+
*
|
|
95
|
+
* @throws {RoleNotFoundError} if the role is not defined
|
|
96
|
+
*/
|
|
97
|
+
getRolePermissions(roleName: string): Permission[] {
|
|
98
|
+
const cached = this.resolvedPermissions.get(roleName)
|
|
99
|
+
if (cached) return cached
|
|
100
|
+
|
|
101
|
+
if (!this.roleMap.has(roleName)) {
|
|
102
|
+
throw new RoleNotFoundError(roleName)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const perms = this.resolvePermissionsForRole(roleName, new Set())
|
|
106
|
+
this.resolvedPermissions.set(roleName, perms)
|
|
107
|
+
return perms
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a role has a specific permission.
|
|
112
|
+
*/
|
|
113
|
+
roleHasPermission(roleName: string, permission: Permission): boolean {
|
|
114
|
+
const perms = this.getRolePermissions(roleName)
|
|
115
|
+
return perms.some((granted) => permissionCovers(granted, permission))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Scope Resolution ---
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register a custom scope resolver for a collection.
|
|
122
|
+
*
|
|
123
|
+
* Custom resolvers override the default scope logic for that collection.
|
|
124
|
+
*/
|
|
125
|
+
registerScopeResolver(collection: string, resolver: CollectionScopeResolver): void {
|
|
126
|
+
this.collectionResolvers.set(collection, resolver)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve sync scopes for a user in an organization.
|
|
131
|
+
*
|
|
132
|
+
* The scopes determine what data the user can see and modify during sync.
|
|
133
|
+
* - Owner/Admin: all org data
|
|
134
|
+
* - Member: all org data (read + write)
|
|
135
|
+
* - Viewer: all org data (read-only)
|
|
136
|
+
* - Billing: no data scopes (billing-only access)
|
|
137
|
+
*
|
|
138
|
+
* Custom collection resolvers can override the defaults.
|
|
139
|
+
*
|
|
140
|
+
* @param collections - List of collection names to resolve scopes for.
|
|
141
|
+
* If not provided, only custom-registered collections are included.
|
|
142
|
+
*/
|
|
143
|
+
async resolveScopes(
|
|
144
|
+
userId: string,
|
|
145
|
+
orgId: string,
|
|
146
|
+
collections?: string[],
|
|
147
|
+
): Promise<SyncScopes | null> {
|
|
148
|
+
const membership = await this.orgStore.getMembership(orgId, userId)
|
|
149
|
+
if (!membership) return null
|
|
150
|
+
|
|
151
|
+
const permissions = this.getRolePermissions(membership.role)
|
|
152
|
+
const ctx: ScopeContext = {
|
|
153
|
+
userId,
|
|
154
|
+
orgId,
|
|
155
|
+
role: membership.role,
|
|
156
|
+
permissions,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scopes: SyncScopes = {}
|
|
160
|
+
|
|
161
|
+
// Check if user has any data permissions at all
|
|
162
|
+
const hasAnyRead = permissions.some((p) => permissionCovers(p, '*:read' as Permission))
|
|
163
|
+
if (!hasAnyRead) {
|
|
164
|
+
// No data access (e.g., billing-only role)
|
|
165
|
+
return scopes
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const isReadOnly = !permissions.some((p) => permissionCovers(p, '*:write' as Permission))
|
|
169
|
+
|
|
170
|
+
const collectionsToResolve = collections ?? [...this.collectionResolvers.keys()]
|
|
171
|
+
|
|
172
|
+
for (const collection of collectionsToResolve) {
|
|
173
|
+
// Check custom resolver first
|
|
174
|
+
const customResolver = this.collectionResolvers.get(collection)
|
|
175
|
+
if (customResolver) {
|
|
176
|
+
const customScope = customResolver(ctx)
|
|
177
|
+
if (customScope) {
|
|
178
|
+
scopes[collection] = customScope
|
|
179
|
+
}
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Default scope: filter by orgId
|
|
184
|
+
const scope: ScopeFilter = { orgId }
|
|
185
|
+
if (isReadOnly) {
|
|
186
|
+
scope.__readonly = true
|
|
187
|
+
}
|
|
188
|
+
scopes[collection] = scope
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return scopes
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Role Management ---
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get all defined role names.
|
|
198
|
+
*/
|
|
199
|
+
getRoleNames(): string[] {
|
|
200
|
+
return [...this.roleMap.keys()]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get a role definition by name.
|
|
205
|
+
*/
|
|
206
|
+
getRoleDefinition(roleName: string): RoleDefinition | null {
|
|
207
|
+
return this.roleMap.get(roleName) ?? null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Private ---
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Validate all role definitions for circular inheritance and unknown references.
|
|
214
|
+
*/
|
|
215
|
+
private validateRoles(): void {
|
|
216
|
+
for (const role of this.roleMap.values()) {
|
|
217
|
+
if (role.inherits) {
|
|
218
|
+
for (const parent of role.inherits) {
|
|
219
|
+
if (!this.roleMap.has(parent)) {
|
|
220
|
+
throw new RoleNotFoundError(parent)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check for circular inheritance
|
|
227
|
+
for (const role of this.roleMap.values()) {
|
|
228
|
+
this.detectCircularInheritance(role.name, new Set())
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Detect circular inheritance in role hierarchy.
|
|
234
|
+
*/
|
|
235
|
+
private detectCircularInheritance(roleName: string, visited: Set<string>): void {
|
|
236
|
+
if (visited.has(roleName)) {
|
|
237
|
+
throw new CircularInheritanceError([...visited, roleName])
|
|
238
|
+
}
|
|
239
|
+
visited.add(roleName)
|
|
240
|
+
|
|
241
|
+
const role = this.roleMap.get(roleName)
|
|
242
|
+
if (role?.inherits) {
|
|
243
|
+
for (const parent of role.inherits) {
|
|
244
|
+
this.detectCircularInheritance(parent, new Set(visited))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Recursively resolve all permissions for a role, including inherited permissions.
|
|
251
|
+
*/
|
|
252
|
+
private resolvePermissionsForRole(roleName: string, visited: Set<string>): Permission[] {
|
|
253
|
+
if (visited.has(roleName)) return []
|
|
254
|
+
visited.add(roleName)
|
|
255
|
+
|
|
256
|
+
const role = this.roleMap.get(roleName)
|
|
257
|
+
if (!role) return []
|
|
258
|
+
|
|
259
|
+
const perms = new Set<Permission>(role.permissions)
|
|
260
|
+
|
|
261
|
+
if (role.inherits) {
|
|
262
|
+
for (const parent of role.inherits) {
|
|
263
|
+
const parentPerms = this.resolvePermissionsForRole(parent, visited)
|
|
264
|
+
for (const p of parentPerms) {
|
|
265
|
+
perms.add(p)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return [...perms]
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// defineRoles builder
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Builder for defining custom roles.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```typescript
|
|
283
|
+
* const roles = defineRoles()
|
|
284
|
+
* .role('viewer', ['*:read'])
|
|
285
|
+
* .role('editor', ['*:write'], { inherits: ['viewer'] })
|
|
286
|
+
* .role('admin', ['org:manage-members'], { inherits: ['editor'] })
|
|
287
|
+
* .build()
|
|
288
|
+
* ```
|
|
289
|
+
*/
|
|
290
|
+
export function defineRoles(): RoleBuilder {
|
|
291
|
+
return new RoleBuilder()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
class RoleBuilder {
|
|
295
|
+
private roles: RoleDefinition[] = []
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Add a role definition.
|
|
299
|
+
*/
|
|
300
|
+
role(name: string, permissions: Permission[], options?: { inherits?: string[] }): RoleBuilder {
|
|
301
|
+
this.roles.push({
|
|
302
|
+
name,
|
|
303
|
+
permissions,
|
|
304
|
+
inherits: options?.inherits,
|
|
305
|
+
})
|
|
306
|
+
return this
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Include the built-in roles as a base.
|
|
311
|
+
*/
|
|
312
|
+
withBuiltInRoles(): RoleBuilder {
|
|
313
|
+
this.roles = [...BUILT_IN_ROLES, ...this.roles]
|
|
314
|
+
return this
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Build and return the role definitions array.
|
|
319
|
+
*/
|
|
320
|
+
build(): RoleDefinition[] {
|
|
321
|
+
return [...this.roles]
|
|
322
|
+
}
|
|
323
|
+
}
|