@donkeylabs/cli 1.1.15 → 1.1.17
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/package.json +1 -1
- package/templates/sveltekit-app/.env.example +3 -1
- package/templates/sveltekit-app/src/lib/permissions.ts +203 -0
- package/templates/sveltekit-app/src/server/index.ts +56 -0
- package/templates/sveltekit-app/src/server/plugins/auth/migrations/004_create_passkeys.ts +60 -0
- package/templates/sveltekit-app/src/server/plugins/email/index.ts +411 -0
- package/templates/sveltekit-app/src/server/plugins/email/migrations/001_create_email_tokens.ts +33 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/index.ts +1045 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts +63 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/002_create_roles.ts +90 -0
- package/templates/sveltekit-app/src/server/plugins/permissions/migrations/003_create_resource_grants.ts +50 -0
- package/templates/sveltekit-app/src/server/routes/permissions/index.ts +248 -0
- package/templates/sveltekit-app/src/server/routes/tenants/index.ts +339 -0
package/templates/sveltekit-app/src/server/plugins/permissions/migrations/001_create_tenants.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
// Tenants table
|
|
5
|
+
await db.schema
|
|
6
|
+
.createTable("tenants")
|
|
7
|
+
.ifNotExists()
|
|
8
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
9
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
10
|
+
.addColumn("slug", "text", (col) => col.notNull().unique())
|
|
11
|
+
.addColumn("settings", "text") // JSON
|
|
12
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
13
|
+
.addColumn("updated_at", "text", (col) => col.notNull())
|
|
14
|
+
.execute();
|
|
15
|
+
|
|
16
|
+
await db.schema
|
|
17
|
+
.createIndex("idx_tenants_slug")
|
|
18
|
+
.ifNotExists()
|
|
19
|
+
.on("tenants")
|
|
20
|
+
.column("slug")
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
// Tenant members table
|
|
24
|
+
await db.schema
|
|
25
|
+
.createTable("tenant_members")
|
|
26
|
+
.ifNotExists()
|
|
27
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
28
|
+
.addColumn("tenant_id", "text", (col) =>
|
|
29
|
+
col.notNull().references("tenants.id").onDelete("cascade")
|
|
30
|
+
)
|
|
31
|
+
.addColumn("user_id", "text", (col) =>
|
|
32
|
+
col.notNull().references("users.id").onDelete("cascade")
|
|
33
|
+
)
|
|
34
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
35
|
+
.execute();
|
|
36
|
+
|
|
37
|
+
await db.schema
|
|
38
|
+
.createIndex("idx_tenant_members_tenant_id")
|
|
39
|
+
.ifNotExists()
|
|
40
|
+
.on("tenant_members")
|
|
41
|
+
.column("tenant_id")
|
|
42
|
+
.execute();
|
|
43
|
+
|
|
44
|
+
await db.schema
|
|
45
|
+
.createIndex("idx_tenant_members_user_id")
|
|
46
|
+
.ifNotExists()
|
|
47
|
+
.on("tenant_members")
|
|
48
|
+
.column("user_id")
|
|
49
|
+
.execute();
|
|
50
|
+
|
|
51
|
+
await db.schema
|
|
52
|
+
.createIndex("idx_tenant_members_unique")
|
|
53
|
+
.ifNotExists()
|
|
54
|
+
.on("tenant_members")
|
|
55
|
+
.columns(["tenant_id", "user_id"])
|
|
56
|
+
.unique()
|
|
57
|
+
.execute();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
61
|
+
await db.schema.dropTable("tenant_members").ifExists().execute();
|
|
62
|
+
await db.schema.dropTable("tenants").ifExists().execute();
|
|
63
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
// Roles table
|
|
5
|
+
await db.schema
|
|
6
|
+
.createTable("roles")
|
|
7
|
+
.ifNotExists()
|
|
8
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
9
|
+
.addColumn("tenant_id", "text", (col) =>
|
|
10
|
+
col.references("tenants.id").onDelete("cascade")
|
|
11
|
+
) // null = global role
|
|
12
|
+
.addColumn("name", "text", (col) => col.notNull())
|
|
13
|
+
.addColumn("description", "text")
|
|
14
|
+
.addColumn("permissions", "text", (col) => col.notNull().defaultTo("[]")) // JSON array
|
|
15
|
+
.addColumn("inherits_from", "text", (col) =>
|
|
16
|
+
col.references("roles.id").onDelete("set null")
|
|
17
|
+
)
|
|
18
|
+
.addColumn("is_default", "integer", (col) => col.notNull().defaultTo(0))
|
|
19
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
20
|
+
.addColumn("updated_at", "text", (col) => col.notNull())
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
await db.schema
|
|
24
|
+
.createIndex("idx_roles_tenant_id")
|
|
25
|
+
.ifNotExists()
|
|
26
|
+
.on("roles")
|
|
27
|
+
.column("tenant_id")
|
|
28
|
+
.execute();
|
|
29
|
+
|
|
30
|
+
await db.schema
|
|
31
|
+
.createIndex("idx_roles_name_tenant")
|
|
32
|
+
.ifNotExists()
|
|
33
|
+
.on("roles")
|
|
34
|
+
.columns(["tenant_id", "name"])
|
|
35
|
+
.execute();
|
|
36
|
+
|
|
37
|
+
// User roles table
|
|
38
|
+
await db.schema
|
|
39
|
+
.createTable("user_roles")
|
|
40
|
+
.ifNotExists()
|
|
41
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
42
|
+
.addColumn("user_id", "text", (col) =>
|
|
43
|
+
col.notNull().references("users.id").onDelete("cascade")
|
|
44
|
+
)
|
|
45
|
+
.addColumn("role_id", "text", (col) =>
|
|
46
|
+
col.notNull().references("roles.id").onDelete("cascade")
|
|
47
|
+
)
|
|
48
|
+
.addColumn("tenant_id", "text", (col) =>
|
|
49
|
+
col.notNull().references("tenants.id").onDelete("cascade")
|
|
50
|
+
)
|
|
51
|
+
.addColumn("assigned_by", "text", (col) =>
|
|
52
|
+
col.references("users.id").onDelete("set null")
|
|
53
|
+
)
|
|
54
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
55
|
+
.execute();
|
|
56
|
+
|
|
57
|
+
await db.schema
|
|
58
|
+
.createIndex("idx_user_roles_user_id")
|
|
59
|
+
.ifNotExists()
|
|
60
|
+
.on("user_roles")
|
|
61
|
+
.column("user_id")
|
|
62
|
+
.execute();
|
|
63
|
+
|
|
64
|
+
await db.schema
|
|
65
|
+
.createIndex("idx_user_roles_role_id")
|
|
66
|
+
.ifNotExists()
|
|
67
|
+
.on("user_roles")
|
|
68
|
+
.column("role_id")
|
|
69
|
+
.execute();
|
|
70
|
+
|
|
71
|
+
await db.schema
|
|
72
|
+
.createIndex("idx_user_roles_tenant_id")
|
|
73
|
+
.ifNotExists()
|
|
74
|
+
.on("user_roles")
|
|
75
|
+
.column("tenant_id")
|
|
76
|
+
.execute();
|
|
77
|
+
|
|
78
|
+
await db.schema
|
|
79
|
+
.createIndex("idx_user_roles_unique")
|
|
80
|
+
.ifNotExists()
|
|
81
|
+
.on("user_roles")
|
|
82
|
+
.columns(["user_id", "role_id", "tenant_id"])
|
|
83
|
+
.unique()
|
|
84
|
+
.execute();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
88
|
+
await db.schema.dropTable("user_roles").ifExists().execute();
|
|
89
|
+
await db.schema.dropTable("roles").ifExists().execute();
|
|
90
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
4
|
+
await db.schema
|
|
5
|
+
.createTable("resource_grants")
|
|
6
|
+
.ifNotExists()
|
|
7
|
+
.addColumn("id", "text", (col) => col.primaryKey())
|
|
8
|
+
.addColumn("tenant_id", "text", (col) =>
|
|
9
|
+
col.notNull().references("tenants.id").onDelete("cascade")
|
|
10
|
+
)
|
|
11
|
+
.addColumn("resource_type", "text", (col) => col.notNull())
|
|
12
|
+
.addColumn("resource_id", "text", (col) => col.notNull())
|
|
13
|
+
.addColumn("grantee_type", "text", (col) => col.notNull()) // "user" | "role"
|
|
14
|
+
.addColumn("grantee_id", "text", (col) => col.notNull())
|
|
15
|
+
.addColumn("permissions", "text", (col) => col.notNull().defaultTo("[]")) // JSON array
|
|
16
|
+
.addColumn("granted_by", "text", (col) =>
|
|
17
|
+
col.references("users.id").onDelete("set null")
|
|
18
|
+
)
|
|
19
|
+
.addColumn("created_at", "text", (col) => col.notNull().defaultTo("CURRENT_TIMESTAMP"))
|
|
20
|
+
.execute();
|
|
21
|
+
|
|
22
|
+
// Index for looking up grants by resource
|
|
23
|
+
await db.schema
|
|
24
|
+
.createIndex("idx_resource_grants_resource")
|
|
25
|
+
.ifNotExists()
|
|
26
|
+
.on("resource_grants")
|
|
27
|
+
.columns(["tenant_id", "resource_type", "resource_id"])
|
|
28
|
+
.execute();
|
|
29
|
+
|
|
30
|
+
// Index for looking up grants by grantee
|
|
31
|
+
await db.schema
|
|
32
|
+
.createIndex("idx_resource_grants_grantee")
|
|
33
|
+
.ifNotExists()
|
|
34
|
+
.on("resource_grants")
|
|
35
|
+
.columns(["tenant_id", "grantee_type", "grantee_id"])
|
|
36
|
+
.execute();
|
|
37
|
+
|
|
38
|
+
// Unique constraint: one grant per resource+grantee combination
|
|
39
|
+
await db.schema
|
|
40
|
+
.createIndex("idx_resource_grants_unique")
|
|
41
|
+
.ifNotExists()
|
|
42
|
+
.on("resource_grants")
|
|
43
|
+
.columns(["tenant_id", "resource_type", "resource_id", "grantee_type", "grantee_id"])
|
|
44
|
+
.unique()
|
|
45
|
+
.execute();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
49
|
+
await db.schema.dropTable("resource_grants").ifExists().execute();
|
|
50
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions Routes - Client-side permission checking
|
|
3
|
+
*
|
|
4
|
+
* These routes allow the frontend to check permissions for UI locking.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createRouter, defineRoute } from "@donkeylabs/server";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
const permissions = createRouter("permissions");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get current user's permission context for a tenant
|
|
14
|
+
* Returns roles and all static permissions
|
|
15
|
+
*/
|
|
16
|
+
permissions.route("context").typed(
|
|
17
|
+
defineRoute({
|
|
18
|
+
input: z.object({
|
|
19
|
+
tenantId: z.string(),
|
|
20
|
+
}),
|
|
21
|
+
output: z.object({
|
|
22
|
+
tenantId: z.string(),
|
|
23
|
+
roles: z.array(z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
name: z.string(),
|
|
26
|
+
})),
|
|
27
|
+
permissions: z.array(z.string()),
|
|
28
|
+
}),
|
|
29
|
+
handle: async (input, ctx) => {
|
|
30
|
+
if (!ctx.user?.id) {
|
|
31
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return ctx.plugins.permissions.getClientContext(ctx.user.id, input.tenantId);
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if user has specific static permissions
|
|
41
|
+
* Returns a map of permission -> boolean
|
|
42
|
+
*/
|
|
43
|
+
permissions.route("check").typed(
|
|
44
|
+
defineRoute({
|
|
45
|
+
input: z.object({
|
|
46
|
+
tenantId: z.string(),
|
|
47
|
+
permissions: z.array(z.string()),
|
|
48
|
+
}),
|
|
49
|
+
output: z.record(z.string(), z.boolean()),
|
|
50
|
+
handle: async (input, ctx) => {
|
|
51
|
+
if (!ctx.user?.id) {
|
|
52
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const results: Record<string, boolean> = {};
|
|
56
|
+
|
|
57
|
+
for (const permission of input.permissions) {
|
|
58
|
+
results[permission] = await ctx.plugins.permissions.hasPermission(
|
|
59
|
+
ctx.user.id,
|
|
60
|
+
input.tenantId,
|
|
61
|
+
permission
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return results;
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if user can access specific resources
|
|
72
|
+
* Returns a map of "resourceType:resourceId:action" -> boolean
|
|
73
|
+
*/
|
|
74
|
+
permissions.route("canAccess").typed(
|
|
75
|
+
defineRoute({
|
|
76
|
+
input: z.object({
|
|
77
|
+
tenantId: z.string(),
|
|
78
|
+
checks: z.array(z.object({
|
|
79
|
+
resourceType: z.string(),
|
|
80
|
+
resourceId: z.string(),
|
|
81
|
+
action: z.enum(["create", "read", "write", "delete", "admin"]),
|
|
82
|
+
ownerId: z.string().optional(), // For owner check
|
|
83
|
+
})),
|
|
84
|
+
}),
|
|
85
|
+
output: z.record(z.string(), z.boolean()),
|
|
86
|
+
handle: async (input, ctx) => {
|
|
87
|
+
if (!ctx.user?.id) {
|
|
88
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const results: Record<string, boolean> = {};
|
|
92
|
+
|
|
93
|
+
for (const check of input.checks) {
|
|
94
|
+
const key = `${check.resourceType}:${check.resourceId}:${check.action}`;
|
|
95
|
+
results[key] = await ctx.plugins.permissions.canAccess(
|
|
96
|
+
ctx.user.id,
|
|
97
|
+
input.tenantId,
|
|
98
|
+
check.resourceType,
|
|
99
|
+
check.resourceId,
|
|
100
|
+
check.action,
|
|
101
|
+
check.ownerId
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return results;
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get all grants for a specific resource
|
|
112
|
+
* Useful for showing "shared with" UI
|
|
113
|
+
*/
|
|
114
|
+
permissions.route("grants").typed(
|
|
115
|
+
defineRoute({
|
|
116
|
+
input: z.object({
|
|
117
|
+
tenantId: z.string(),
|
|
118
|
+
resourceType: z.string(),
|
|
119
|
+
resourceId: z.string(),
|
|
120
|
+
}),
|
|
121
|
+
output: z.array(z.object({
|
|
122
|
+
resourceType: z.string(),
|
|
123
|
+
resourceId: z.string(),
|
|
124
|
+
granteeType: z.enum(["user", "role"]),
|
|
125
|
+
granteeId: z.string(),
|
|
126
|
+
permissions: z.array(z.enum(["create", "read", "write", "delete", "admin"])),
|
|
127
|
+
})),
|
|
128
|
+
handle: async (input, ctx) => {
|
|
129
|
+
if (!ctx.user?.id) {
|
|
130
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check if user can admin this resource (to see grants)
|
|
134
|
+
const canAdmin = await ctx.plugins.permissions.canAccess(
|
|
135
|
+
ctx.user.id,
|
|
136
|
+
input.tenantId,
|
|
137
|
+
input.resourceType,
|
|
138
|
+
input.resourceId,
|
|
139
|
+
"admin"
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!canAdmin) {
|
|
143
|
+
throw ctx.errors.Forbidden("Cannot view grants for this resource");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return ctx.plugins.permissions.getResourceGrants(
|
|
147
|
+
input.tenantId,
|
|
148
|
+
input.resourceType,
|
|
149
|
+
input.resourceId
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Grant access to a resource
|
|
157
|
+
*/
|
|
158
|
+
permissions.route("grant").typed(
|
|
159
|
+
defineRoute({
|
|
160
|
+
input: z.object({
|
|
161
|
+
tenantId: z.string(),
|
|
162
|
+
resourceType: z.string(),
|
|
163
|
+
resourceId: z.string(),
|
|
164
|
+
granteeType: z.enum(["user", "role"]),
|
|
165
|
+
granteeId: z.string(),
|
|
166
|
+
permissions: z.array(z.enum(["create", "read", "write", "delete", "admin"])),
|
|
167
|
+
}),
|
|
168
|
+
output: z.object({ success: z.boolean() }),
|
|
169
|
+
handle: async (input, ctx) => {
|
|
170
|
+
if (!ctx.user?.id) {
|
|
171
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if user can admin this resource
|
|
175
|
+
const canAdmin = await ctx.plugins.permissions.canAccess(
|
|
176
|
+
ctx.user.id,
|
|
177
|
+
input.tenantId,
|
|
178
|
+
input.resourceType,
|
|
179
|
+
input.resourceId,
|
|
180
|
+
"admin"
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (!canAdmin) {
|
|
184
|
+
throw ctx.errors.Forbidden("Cannot grant access to this resource");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await ctx.plugins.permissions.grantAccess({
|
|
188
|
+
tenantId: input.tenantId,
|
|
189
|
+
resourceType: input.resourceType,
|
|
190
|
+
resourceId: input.resourceId,
|
|
191
|
+
granteeType: input.granteeType,
|
|
192
|
+
granteeId: input.granteeId,
|
|
193
|
+
permissions: input.permissions,
|
|
194
|
+
grantedBy: ctx.user.id,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return { success: true };
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Revoke access to a resource
|
|
204
|
+
*/
|
|
205
|
+
permissions.route("revoke").typed(
|
|
206
|
+
defineRoute({
|
|
207
|
+
input: z.object({
|
|
208
|
+
tenantId: z.string(),
|
|
209
|
+
resourceType: z.string(),
|
|
210
|
+
resourceId: z.string(),
|
|
211
|
+
granteeType: z.enum(["user", "role"]),
|
|
212
|
+
granteeId: z.string(),
|
|
213
|
+
permissions: z.array(z.enum(["create", "read", "write", "delete", "admin"])).optional(),
|
|
214
|
+
}),
|
|
215
|
+
output: z.object({ success: z.boolean() }),
|
|
216
|
+
handle: async (input, ctx) => {
|
|
217
|
+
if (!ctx.user?.id) {
|
|
218
|
+
throw ctx.errors.Unauthorized("Authentication required");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check if user can admin this resource
|
|
222
|
+
const canAdmin = await ctx.plugins.permissions.canAccess(
|
|
223
|
+
ctx.user.id,
|
|
224
|
+
input.tenantId,
|
|
225
|
+
input.resourceType,
|
|
226
|
+
input.resourceId,
|
|
227
|
+
"admin"
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (!canAdmin) {
|
|
231
|
+
throw ctx.errors.Forbidden("Cannot revoke access to this resource");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await ctx.plugins.permissions.revokeAccess({
|
|
235
|
+
tenantId: input.tenantId,
|
|
236
|
+
resourceType: input.resourceType,
|
|
237
|
+
resourceId: input.resourceId,
|
|
238
|
+
granteeType: input.granteeType,
|
|
239
|
+
granteeId: input.granteeId,
|
|
240
|
+
permissions: input.permissions,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return { success: true };
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
export { permissions as permissionsRouter };
|