@permitio/permit-strapi 1.0.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.
@@ -0,0 +1,1111 @@
1
+ import { Permit } from "permitio";
2
+ const debugLog = (strapi, message) => {
3
+ if (process.env.PERMIT_STRAPI_DEBUG === "true") {
4
+ strapi.log.info(message);
5
+ }
6
+ };
7
+ const bootstrap = async ({ strapi }) => {
8
+ strapi.log.info("[permit-strapi] bootstrap running - v2");
9
+ const config2 = await strapi.plugin("permit-strapi").service("config").getConfig();
10
+ if (!config2?.apiKey) {
11
+ debugLog(strapi, "[permit-strapi] No config found, skipping initialization");
12
+ return;
13
+ }
14
+ strapi.permit = new Permit({
15
+ token: config2.apiKey,
16
+ pdp: config2.pdpUrl
17
+ });
18
+ debugLog(strapi, "[permit-strapi] Permit.io client initialized");
19
+ const excludedResources = await strapi.plugin("permit-strapi").service("config").getExcludedResources();
20
+ await strapi.plugin("permit-strapi").service("config").syncResourcesToPermit(excludedResources);
21
+ await strapi.plugin("permit-strapi").service("roles").syncRolesToPermit();
22
+ };
23
+ const destroy = ({ strapi }) => {
24
+ };
25
+ const deriveAction = (method, hasId) => {
26
+ if (method === "GET") return hasId ? "findOne" : "find";
27
+ if (method === "POST") return "create";
28
+ if (method === "PUT" || method === "PATCH") return "update";
29
+ if (method === "DELETE") return "delete";
30
+ return null;
31
+ };
32
+ const extractAttributes = (entity, mappedFields) => {
33
+ const attributes = {};
34
+ for (const field of mappedFields) {
35
+ const value = entity[field];
36
+ if (value === void 0 || value === null) continue;
37
+ if (typeof value === "object" && !Array.isArray(value) && value.id !== void 0) {
38
+ attributes[`${field}Id`] = value.id;
39
+ } else {
40
+ attributes[field] = value;
41
+ }
42
+ }
43
+ return attributes;
44
+ };
45
+ const permitAuthMiddleware = ({ strapi }) => {
46
+ return async (ctx, next) => {
47
+ if (!ctx.request.url.startsWith("/api/")) {
48
+ return next();
49
+ }
50
+ const authHeader = ctx.request.headers.authorization;
51
+ if (!authHeader?.startsWith("Bearer ")) {
52
+ return next();
53
+ }
54
+ const permit = strapi.permit;
55
+ if (!permit) {
56
+ return next();
57
+ }
58
+ const token = authHeader.slice(7);
59
+ let userId;
60
+ let decodedId;
61
+ try {
62
+ const jwtService = strapi.plugin("users-permissions").service("jwt");
63
+ const decoded = await jwtService.verify(token);
64
+ decodedId = decoded.id;
65
+ userId = `strapi-${decoded.id}`;
66
+ } catch {
67
+ return next();
68
+ }
69
+ const urlPath = ctx.request.url.split("?")[0];
70
+ const pathParts = urlPath.split("/").filter(Boolean);
71
+ if (pathParts.length < 2) return next();
72
+ const resourceSlug = pathParts[1];
73
+ const hasId = pathParts.length > 2;
74
+ const resourceId = hasId ? pathParts[2] : null;
75
+ const ctEntry = Object.values(strapi.contentTypes).filter((ct) => ct.uid.startsWith("api::")).find(
76
+ (ct) => ct.info?.pluralName === resourceSlug || ct.info?.singularName === resourceSlug
77
+ );
78
+ if (!ctEntry) {
79
+ return next();
80
+ }
81
+ const resourceUid = ctEntry.uid;
82
+ const resourceName = resourceUid.split("::")[1]?.split(".")[0];
83
+ const configService2 = strapi.plugin("permit-strapi").service("config");
84
+ const excludedResources = await configService2.getExcludedResources();
85
+ if (excludedResources.includes(resourceUid)) {
86
+ debugLog(strapi, `[permit-strapi] Skipping excluded resource: ${resourceUid}`);
87
+ return next();
88
+ }
89
+ const action = deriveAction(ctx.request.method, hasId);
90
+ if (!action) return next();
91
+ const userAttrMappings = await configService2.getUserAttributeMappings();
92
+ let userObj = userId;
93
+ if (userAttrMappings.length > 0) {
94
+ try {
95
+ const fullUser = await strapi.db.query("plugin::users-permissions.user").findOne({
96
+ where: { id: decodedId },
97
+ populate: userAttrMappings
98
+ });
99
+ if (fullUser) {
100
+ const attrs = extractAttributes(fullUser, userAttrMappings);
101
+ attrs.strapiId = decodedId;
102
+ userObj = { key: userId, attributes: attrs };
103
+ }
104
+ } catch (err) {
105
+ strapi.log.warn(`[permit-strapi] Failed to fetch user attributes: ${err.message}`);
106
+ }
107
+ }
108
+ const resourceAttrMappings = await configService2.getResourceAttributeMappings();
109
+ const mappedResourceFields = resourceAttrMappings[resourceUid] || [];
110
+ if (action === "find" && mappedResourceFields.length > 0) {
111
+ await next();
112
+ if (ctx.status !== 200 || !ctx.body?.data || !Array.isArray(ctx.body.data)) return;
113
+ const entities = ctx.body.data;
114
+ if (entities.length === 0) return;
115
+ try {
116
+ const documentIds = entities.map((e) => e.documentId);
117
+ const fullEntities = await strapi.db.query(resourceUid).findMany({
118
+ where: { documentId: { $in: documentIds } },
119
+ populate: mappedResourceFields
120
+ });
121
+ const entityMap = /* @__PURE__ */ new Map();
122
+ for (const e of fullEntities) {
123
+ if (!entityMap.has(e.documentId)) {
124
+ entityMap.set(e.documentId, e);
125
+ }
126
+ }
127
+ const checks = entities.map((entity) => {
128
+ const full = entityMap.get(entity.documentId);
129
+ const attrs = full ? extractAttributes(full, mappedResourceFields) : {};
130
+ return {
131
+ user: userObj,
132
+ action: "find",
133
+ resource: { type: resourceName, key: entity.documentId, attributes: attrs }
134
+ };
135
+ });
136
+ debugLog(
137
+ strapi,
138
+ `[permit-strapi] bulkCheck: user=${typeof userObj === "string" ? userObj : userObj.key} action=find resource=${resourceName} count=${checks.length}`
139
+ );
140
+ const results = await permit.bulkCheck(checks);
141
+ const allowed = entities.filter((_, i) => results[i]);
142
+ const denied = entities.length - allowed.length;
143
+ if (denied > 0) {
144
+ debugLog(
145
+ strapi,
146
+ `[permit-strapi] bulkCheck filtered: ${allowed.length} allowed, ${denied} denied out of ${entities.length} ${resourceName}(s)`
147
+ );
148
+ }
149
+ ctx.body = {
150
+ ...ctx.body,
151
+ data: allowed,
152
+ meta: {
153
+ ...ctx.body.meta,
154
+ pagination: ctx.body.meta?.pagination ? { ...ctx.body.meta.pagination, total: allowed.length } : void 0
155
+ }
156
+ };
157
+ } catch (err) {
158
+ strapi.log.error(`[permit-strapi] bulkCheck failed, returning unfiltered: ${err.message}`);
159
+ }
160
+ return;
161
+ }
162
+ let resourceObj = resourceName;
163
+ if (hasId && resourceId) {
164
+ if (mappedResourceFields.length > 0) {
165
+ try {
166
+ const entity = await strapi.db.query(resourceUid).findOne({
167
+ where: { documentId: resourceId },
168
+ populate: mappedResourceFields
169
+ });
170
+ if (entity) {
171
+ resourceObj = { type: resourceName, key: resourceId, attributes: extractAttributes(entity, mappedResourceFields) };
172
+ } else {
173
+ resourceObj = { type: resourceName, key: resourceId };
174
+ }
175
+ } catch (err) {
176
+ strapi.log.warn(`[permit-strapi] Failed to fetch resource attributes: ${err.message}`);
177
+ resourceObj = { type: resourceName, key: resourceId };
178
+ }
179
+ } else {
180
+ resourceObj = { type: resourceName, key: resourceId };
181
+ }
182
+ }
183
+ debugLog(
184
+ strapi,
185
+ `[permit-strapi] check: user=${typeof userObj === "string" ? userObj : userObj.key} action=${action} resource=${typeof resourceObj === "string" ? resourceObj : `${resourceObj.type}(key=${resourceObj.key ?? "none"}, attrs=${JSON.stringify(resourceObj.attributes || {})})`}`
186
+ );
187
+ try {
188
+ const permitted = await permit.check(userObj, action, resourceObj);
189
+ if (!permitted) {
190
+ strapi.log.warn(
191
+ `[permit-strapi] DENIED: user=${userId} action=${action} resource=${resourceName}`
192
+ );
193
+ ctx.status = 403;
194
+ ctx.body = {
195
+ data: null,
196
+ error: {
197
+ status: 403,
198
+ name: "ForbiddenError",
199
+ message: "You are not authorized to perform this action",
200
+ details: {}
201
+ }
202
+ };
203
+ return;
204
+ }
205
+ debugLog(strapi, `[permit-strapi] ALLOWED: user=${userId} action=${action} resource=${resourceName}`);
206
+ return next();
207
+ } catch (error) {
208
+ strapi.log.error(`[permit-strapi] permit.check() failed: ${error.message}`);
209
+ return next();
210
+ }
211
+ };
212
+ };
213
+ const register = ({ strapi }) => {
214
+ strapi.server.use(permitAuthMiddleware({ strapi }));
215
+ strapi.db.lifecycles.subscribe({
216
+ models: ["plugin::users-permissions.role"],
217
+ async afterCreate({ result }) {
218
+ await strapi.plugin("permit-strapi").service("roles").createOrUpdatePermitRole(result);
219
+ },
220
+ async afterUpdate({ result }) {
221
+ await strapi.plugin("permit-strapi").service("roles").createOrUpdatePermitRole(result);
222
+ },
223
+ async afterDelete({ result }) {
224
+ await strapi.plugin("permit-strapi").service("roles").deletePermitRole(result);
225
+ }
226
+ });
227
+ strapi.db.lifecycles.subscribe({
228
+ models: ["plugin::users-permissions.user"],
229
+ async afterCreate({ result }) {
230
+ await strapi.plugin("permit-strapi").service("users").syncUserToPermit(result);
231
+ },
232
+ async afterUpdate({ result }) {
233
+ await strapi.plugin("permit-strapi").service("users").syncUserToPermit(result);
234
+ },
235
+ async afterDelete({ result }) {
236
+ await strapi.plugin("permit-strapi").service("users").deletePermitUser(result);
237
+ }
238
+ });
239
+ strapi.db.lifecycles.subscribe({
240
+ async afterCreate(event) {
241
+ const { model, result } = event;
242
+ if (!model?.uid?.startsWith("api::") || !result?.documentId) return;
243
+ const permit = strapi.permit;
244
+ if (!permit) return;
245
+ const configService2 = strapi.plugin("permit-strapi").service("config");
246
+ const excluded = await configService2.getExcludedResources();
247
+ if (excluded.includes(model.uid)) return;
248
+ const rebacConfig = await configService2.getRebacConfig();
249
+ if (!rebacConfig[model.uid]?.enabled) return;
250
+ await strapi.plugin("permit-strapi").service("instances").createResourceInstance(model.uid, result.documentId);
251
+ },
252
+ async afterDelete(event) {
253
+ const { model, result } = event;
254
+ if (!model?.uid?.startsWith("api::") || !result?.documentId) return;
255
+ const permit = strapi.permit;
256
+ if (!permit) return;
257
+ const configService2 = strapi.plugin("permit-strapi").service("config");
258
+ const excluded = await configService2.getExcludedResources();
259
+ if (excluded.includes(model.uid)) return;
260
+ const rebacConfig = await configService2.getRebacConfig();
261
+ if (!rebacConfig[model.uid]?.enabled) return;
262
+ await strapi.plugin("permit-strapi").service("instances").deleteResourceInstance(model.uid, result.documentId);
263
+ }
264
+ });
265
+ };
266
+ const config = {
267
+ default: {},
268
+ validator() {
269
+ }
270
+ };
271
+ const contentTypes = {};
272
+ const controller = ({ strapi }) => ({
273
+ index(ctx) {
274
+ ctx.body = strapi.plugin("permit-strapi").service("service").getWelcomeMessage();
275
+ }
276
+ });
277
+ const configController = ({ strapi }) => ({
278
+ async saveConfig(ctx) {
279
+ const { apiKey, pdpUrl } = ctx.request.body;
280
+ if (!apiKey) {
281
+ return ctx.badRequest("API key is required");
282
+ }
283
+ if (!pdpUrl) {
284
+ return ctx.badRequest("PDP URL is required");
285
+ }
286
+ try {
287
+ const result = await strapi.plugin("permit-strapi").service("config").validateAndSave({ apiKey, pdpUrl });
288
+ ctx.body = result;
289
+ } catch (error) {
290
+ strapi.log.error(`[permit-strapi] saveConfig failed: ${error.message}`);
291
+ return ctx.badRequest(error.message);
292
+ }
293
+ },
294
+ async getExcludedResources(ctx) {
295
+ const excludedResources = await strapi.plugin("permit-strapi").service("config").getExcludedResources();
296
+ ctx.body = { excludedResources };
297
+ },
298
+ async saveExcludedResources(ctx) {
299
+ const { excludedResources } = ctx.request.body;
300
+ if (!Array.isArray(excludedResources)) {
301
+ return ctx.badRequest("excludedResources must be an array");
302
+ }
303
+ await strapi.plugin("permit-strapi").service("config").saveExcludedResources(excludedResources);
304
+ ctx.body = { success: true };
305
+ },
306
+ async deleteConfig(ctx) {
307
+ const store = await strapi.plugin("permit-strapi").service("config").getStore();
308
+ await store.delete({ key: "config" });
309
+ strapi.permit = null;
310
+ ctx.body = { success: true };
311
+ },
312
+ async getContentTypes(ctx) {
313
+ const contentTypes2 = Object.values(strapi.contentTypes).filter((ct) => ct.kind === "collectionType" && ct.uid.startsWith("api::")).map((ct) => ({
314
+ uid: ct.uid,
315
+ displayName: ct.info?.displayName || ct.uid,
316
+ apiID: ct.info?.singularName || ct.uid
317
+ }));
318
+ ctx.body = { contentTypes: contentTypes2 };
319
+ },
320
+ async syncAllUsers(ctx) {
321
+ try {
322
+ const result = await strapi.plugin("permit-strapi").service("users").syncAllUsers();
323
+ ctx.body = result;
324
+ } catch (error) {
325
+ return ctx.badRequest(error.message);
326
+ }
327
+ },
328
+ async getConfig(ctx) {
329
+ const config2 = await strapi.plugin("permit-strapi").service("config").getConfig();
330
+ if (!config2) {
331
+ return ctx.body = { configured: false };
332
+ }
333
+ const maskedApiKey = `${"*".repeat(8)}${config2.apiKey.slice(-4)}`;
334
+ ctx.body = {
335
+ configured: true,
336
+ pdpUrl: config2.pdpUrl,
337
+ apiKey: maskedApiKey
338
+ };
339
+ },
340
+ async getUserAttributeMappings(ctx) {
341
+ const mappings = await strapi.plugin("permit-strapi").service("config").getUserAttributeMappings();
342
+ ctx.body = { mappings };
343
+ },
344
+ async saveUserAttributeMappings(ctx) {
345
+ const { mappings } = ctx.request.body;
346
+ if (!Array.isArray(mappings)) {
347
+ return ctx.badRequest("mappings must be an array of field names");
348
+ }
349
+ const configService2 = strapi.plugin("permit-strapi").service("config");
350
+ await configService2.saveUserAttributeMappings(mappings);
351
+ const excludedResources = await configService2.getExcludedResources();
352
+ await configService2.syncResourcesToPermit(excludedResources);
353
+ ctx.body = { success: true };
354
+ },
355
+ async getResourceAttributeMappings(ctx) {
356
+ const mappings = await strapi.plugin("permit-strapi").service("config").getResourceAttributeMappings();
357
+ ctx.body = { mappings };
358
+ },
359
+ async saveResourceAttributeMappings(ctx) {
360
+ const { mappings } = ctx.request.body;
361
+ if (!mappings || typeof mappings !== "object") {
362
+ return ctx.badRequest("mappings must be an object of { uid: string[] }");
363
+ }
364
+ const configService2 = strapi.plugin("permit-strapi").service("config");
365
+ await configService2.saveResourceAttributeMappings(mappings);
366
+ const excludedResources = await configService2.getExcludedResources();
367
+ await configService2.syncResourcesToPermit(excludedResources);
368
+ ctx.body = { success: true };
369
+ },
370
+ async getContentTypeFields(ctx) {
371
+ const { uid } = ctx.params;
372
+ strapi.log.info(`[permit-strapi] getContentTypeFields called for: ${uid}`);
373
+ const ct = strapi.contentTypes[uid];
374
+ strapi.log.info(`[permit-strapi] raw attributes for ${uid}: ${JSON.stringify(ct?.attributes)}`);
375
+ if (!ct) {
376
+ return ctx.notFound(`Content type ${uid} not found`);
377
+ }
378
+ const scalarTypes = [
379
+ "string",
380
+ "text",
381
+ "richtext",
382
+ "email",
383
+ "uid",
384
+ "enumeration",
385
+ "integer",
386
+ "biginteger",
387
+ "float",
388
+ "decimal",
389
+ "boolean",
390
+ "date",
391
+ "datetime",
392
+ "time",
393
+ "json",
394
+ "relation"
395
+ ];
396
+ const fields = Object.entries(ct.attributes || {}).filter(([, attr]) => scalarTypes.includes(attr.type)).map(([name, attr]) => ({
397
+ name,
398
+ type: attr.type
399
+ }));
400
+ strapi.log.info(`[permit-strapi] fields for ${uid}: ${JSON.stringify(fields)}`);
401
+ ctx.body = { fields };
402
+ },
403
+ async getUserFields(ctx) {
404
+ const userCt = strapi.contentTypes["plugin::users-permissions.user"];
405
+ if (!userCt) {
406
+ return ctx.notFound("User content type not found");
407
+ }
408
+ const scalarTypes = [
409
+ "string",
410
+ "text",
411
+ "email",
412
+ "enumeration",
413
+ "integer",
414
+ "biginteger",
415
+ "float",
416
+ "decimal",
417
+ "boolean",
418
+ "date",
419
+ "datetime",
420
+ "time",
421
+ "relation"
422
+ ];
423
+ const systemFields = ["password", "resetPasswordToken", "confirmationToken", "provider", "confirmed", "blocked"];
424
+ const fields = Object.entries(userCt.attributes || {}).filter(
425
+ ([name, attr]) => scalarTypes.includes(attr.type) && !systemFields.includes(name)
426
+ ).map(([name, attr]) => ({
427
+ name,
428
+ type: attr.type
429
+ }));
430
+ ctx.body = { fields };
431
+ }
432
+ });
433
+ const extractResourceKey$2 = (uid) => uid.split("::")[1]?.split(".")[0] ?? uid;
434
+ const rebacController = ({ strapi }) => ({
435
+ async getRebacConfig(ctx) {
436
+ const config2 = await strapi.plugin("permit-strapi").service("config").getRebacConfig();
437
+ ctx.body = { config: config2 };
438
+ },
439
+ async saveRebacConfig(ctx) {
440
+ const { config: config2 } = ctx.request.body;
441
+ if (!config2 || typeof config2 !== "object") {
442
+ return ctx.badRequest("config must be an object");
443
+ }
444
+ await strapi.plugin("permit-strapi").service("config").saveRebacConfig(config2);
445
+ ctx.body = { success: true };
446
+ },
447
+ async syncInstances(ctx) {
448
+ const uid = decodeURIComponent(ctx.params.uid);
449
+ const ct = strapi.contentTypes[uid];
450
+ if (!ct || !uid.startsWith("api::")) {
451
+ return ctx.notFound(`Content type ${uid} not found`);
452
+ }
453
+ try {
454
+ const result = await strapi.plugin("permit-strapi").service("instances").syncAllInstances(uid);
455
+ ctx.body = result;
456
+ } catch (error) {
457
+ return ctx.badRequest(error.message);
458
+ }
459
+ },
460
+ async getInstanceRoles(ctx) {
461
+ const uid = decodeURIComponent(ctx.params.uid);
462
+ const store = await strapi.plugin("permit-strapi").service("config").getStore();
463
+ const allRoles = await store.get({ key: "rebacInstanceRoles" });
464
+ ctx.body = { roles: allRoles?.[uid] || [] };
465
+ },
466
+ /**
467
+ * Saves instance roles to the Strapi store and syncs them to Permit.io
468
+ * as resource roles on the corresponding resource.
469
+ *
470
+ * Body: `{ roles: [{ key: "owner", name: "Owner" }, ...] }`
471
+ */
472
+ async saveInstanceRoles(ctx) {
473
+ const uid = decodeURIComponent(ctx.params.uid);
474
+ const { roles } = ctx.request.body;
475
+ if (!Array.isArray(roles)) {
476
+ return ctx.badRequest("roles must be an array of { key, name }");
477
+ }
478
+ const ct = strapi.contentTypes[uid];
479
+ if (!ct || !uid.startsWith("api::")) {
480
+ return ctx.notFound(`Content type ${uid} not found`);
481
+ }
482
+ const store = await strapi.plugin("permit-strapi").service("config").getStore();
483
+ const allRoles = await store.get({ key: "rebacInstanceRoles" }) || {};
484
+ allRoles[uid] = roles;
485
+ await store.set({ key: "rebacInstanceRoles", value: allRoles });
486
+ const permit = strapi.permit;
487
+ if (permit) {
488
+ const resourceKey = extractResourceKey$2(uid);
489
+ for (const role of roles) {
490
+ try {
491
+ await permit.api.resourceRoles.create(resourceKey, {
492
+ key: role.key,
493
+ name: role.name
494
+ });
495
+ debugLog(strapi, `[permit-strapi] Created resource role "${role.key}" on ${resourceKey}`);
496
+ } catch {
497
+ try {
498
+ await permit.api.resourceRoles.update(resourceKey, role.key, { name: role.name });
499
+ debugLog(strapi, `[permit-strapi] Updated resource role "${role.key}" on ${resourceKey}`);
500
+ } catch (updateError) {
501
+ strapi.log.warn(`[permit-strapi] Could not sync role "${role.key}" on ${resourceKey}: ${updateError.message}`);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ ctx.body = { success: true };
507
+ }
508
+ });
509
+ const controllers = {
510
+ controller,
511
+ config: configController,
512
+ rebac: rebacController
513
+ };
514
+ const middlewares = {};
515
+ const policies = {};
516
+ const contentAPIRoutes = () => ({
517
+ type: "content-api",
518
+ routes: [
519
+ {
520
+ method: "GET",
521
+ path: "/",
522
+ // name of the controller file & the method.
523
+ handler: "controller.index",
524
+ config: {
525
+ policies: []
526
+ }
527
+ }
528
+ ]
529
+ });
530
+ const adminAPIRoutes = () => ({
531
+ type: "admin",
532
+ routes: [
533
+ {
534
+ method: "POST",
535
+ path: "/config",
536
+ handler: "config.saveConfig",
537
+ config: {
538
+ policies: []
539
+ }
540
+ },
541
+ {
542
+ method: "GET",
543
+ path: "/config",
544
+ handler: "config.getConfig",
545
+ config: {
546
+ policies: []
547
+ }
548
+ },
549
+ {
550
+ method: "GET",
551
+ path: "/excluded-resources",
552
+ handler: "config.getExcludedResources",
553
+ config: { policies: [] }
554
+ },
555
+ {
556
+ method: "POST",
557
+ path: "/excluded-resources",
558
+ handler: "config.saveExcludedResources",
559
+ config: { policies: [] }
560
+ },
561
+ {
562
+ method: "DELETE",
563
+ path: "/config",
564
+ handler: "config.deleteConfig",
565
+ config: {
566
+ policies: []
567
+ }
568
+ },
569
+ {
570
+ method: "GET",
571
+ path: "/content-types",
572
+ handler: "config.getContentTypes",
573
+ config: {
574
+ policies: []
575
+ }
576
+ },
577
+ {
578
+ method: "POST",
579
+ path: "/sync-users",
580
+ handler: "config.syncAllUsers",
581
+ config: { policies: [] }
582
+ },
583
+ // ABAC Attribute Mapping Routes
584
+ {
585
+ method: "GET",
586
+ path: "/user-attribute-mappings",
587
+ handler: "config.getUserAttributeMappings",
588
+ config: { policies: [] }
589
+ },
590
+ {
591
+ method: "POST",
592
+ path: "/user-attribute-mappings",
593
+ handler: "config.saveUserAttributeMappings",
594
+ config: { policies: [] }
595
+ },
596
+ {
597
+ method: "GET",
598
+ path: "/resource-attribute-mappings",
599
+ handler: "config.getResourceAttributeMappings",
600
+ config: { policies: [] }
601
+ },
602
+ {
603
+ method: "POST",
604
+ path: "/resource-attribute-mappings",
605
+ handler: "config.saveResourceAttributeMappings",
606
+ config: { policies: [] }
607
+ },
608
+ {
609
+ method: "GET",
610
+ path: "/content-type-fields/:uid",
611
+ handler: "config.getContentTypeFields",
612
+ config: { policies: [] }
613
+ },
614
+ {
615
+ method: "GET",
616
+ path: "/user-fields",
617
+ handler: "config.getUserFields",
618
+ config: { policies: [] }
619
+ },
620
+ // ReBAC Routes
621
+ {
622
+ method: "GET",
623
+ path: "/rebac-config",
624
+ handler: "rebac.getRebacConfig",
625
+ config: { policies: [] }
626
+ },
627
+ {
628
+ method: "POST",
629
+ path: "/rebac-config",
630
+ handler: "rebac.saveRebacConfig",
631
+ config: { policies: [] }
632
+ },
633
+ {
634
+ method: "POST",
635
+ path: "/sync-instances/:uid",
636
+ handler: "rebac.syncInstances",
637
+ config: { policies: [] }
638
+ },
639
+ {
640
+ method: "GET",
641
+ path: "/instance-roles/:uid",
642
+ handler: "rebac.getInstanceRoles",
643
+ config: { policies: [] }
644
+ },
645
+ {
646
+ method: "POST",
647
+ path: "/instance-roles/:uid",
648
+ handler: "rebac.saveInstanceRoles",
649
+ config: { policies: [] }
650
+ }
651
+ ]
652
+ });
653
+ const routes = {
654
+ "content-api": contentAPIRoutes,
655
+ admin: adminAPIRoutes
656
+ };
657
+ const service = ({ strapi }) => ({
658
+ getWelcomeMessage() {
659
+ return "Welcome to Strapi 🚀";
660
+ }
661
+ });
662
+ const STORE_KEY = "config";
663
+ const RESOURCE_ACTIONS = {
664
+ find: { name: "Find" },
665
+ findOne: { name: "Find One" },
666
+ create: { name: "Create" },
667
+ update: { name: "Update" },
668
+ delete: { name: "Delete" }
669
+ };
670
+ const STRAPI_TO_PERMIT_TYPE = {
671
+ string: "string",
672
+ text: "string",
673
+ richtext: "string",
674
+ email: "string",
675
+ uid: "string",
676
+ enumeration: "string",
677
+ integer: "number",
678
+ biginteger: "number",
679
+ float: "number",
680
+ decimal: "number",
681
+ boolean: "bool",
682
+ date: "string",
683
+ datetime: "string",
684
+ time: "string",
685
+ json: "json",
686
+ relation: "number"
687
+ };
688
+ const extractResourceKey$1 = (uid) => uid.split("::")[1]?.split(".")[0] ?? uid;
689
+ const configService = ({ strapi }) => ({
690
+ async getStore() {
691
+ return strapi.store({ type: "plugin", name: "permit-strapi" });
692
+ },
693
+ async getConfig() {
694
+ const store = await this.getStore();
695
+ return store.get({ key: STORE_KEY });
696
+ },
697
+ async saveConfig({ apiKey, pdpUrl }) {
698
+ const store = await this.getStore();
699
+ await store.set({ key: STORE_KEY, value: { apiKey, pdpUrl } });
700
+ },
701
+ async getExcludedResources() {
702
+ const store = await this.getStore();
703
+ const excluded = await store.get({ key: "excludedResources" });
704
+ return excluded || [];
705
+ },
706
+ async getUserAttributeMappings() {
707
+ const store = await this.getStore();
708
+ const mappings = await store.get({ key: "userAttributeMappings" });
709
+ return mappings || [];
710
+ },
711
+ async saveUserAttributeMappings(mappings) {
712
+ const store = await this.getStore();
713
+ await store.set({ key: "userAttributeMappings", value: mappings });
714
+ debugLog(strapi, `[permit-strapi] Saved user attribute mappings: ${JSON.stringify(mappings)}`);
715
+ },
716
+ async getResourceAttributeMappings() {
717
+ const store = await this.getStore();
718
+ const mappings = await store.get({ key: "resourceAttributeMappings" });
719
+ return mappings || {};
720
+ },
721
+ async saveResourceAttributeMappings(mappings) {
722
+ const store = await this.getStore();
723
+ await store.set({ key: "resourceAttributeMappings", value: mappings });
724
+ debugLog(strapi, `[permit-strapi] Saved resource attribute mappings: ${JSON.stringify(mappings)}`);
725
+ },
726
+ /** ReBAC config keyed by CT UID: `{ "api::post.post": { enabled: true, creatorRole: "owner" } }` */
727
+ async getRebacConfig() {
728
+ const store = await this.getStore();
729
+ const config2 = await store.get({ key: "rebacConfig" });
730
+ return config2 || {};
731
+ },
732
+ async saveRebacConfig(config2) {
733
+ const store = await this.getStore();
734
+ await store.set({ key: "rebacConfig", value: config2 });
735
+ debugLog(strapi, `[permit-strapi] Saved ReBAC config: ${JSON.stringify(config2)}`);
736
+ },
737
+ /** Converts mapped Strapi CT fields into the Permit.io attribute schema format. */
738
+ buildAttributeSchema(ctUid, mappedFields) {
739
+ const ct = strapi.contentTypes[ctUid];
740
+ if (!ct || !ct.attributes || mappedFields.length === 0) return {};
741
+ const schema = {};
742
+ for (const field of mappedFields) {
743
+ const attr = ct.attributes[field];
744
+ if (!attr) continue;
745
+ const permitType = STRAPI_TO_PERMIT_TYPE[attr.type];
746
+ if (!permitType) continue;
747
+ const schemaKey = attr.type === "relation" ? `${field}Id` : field;
748
+ schema[schemaKey] = {
749
+ type: permitType,
750
+ description: `${schemaKey} (${attr.type})`
751
+ };
752
+ }
753
+ return schema;
754
+ },
755
+ /** Upserts all non-excluded CTs as Permit.io resources. Safe to call on every bootstrap. */
756
+ async syncResourcesToPermit(excludedResources) {
757
+ const permit = strapi.permit;
758
+ if (!permit) return;
759
+ const resourceAttrMappings = await this.getResourceAttributeMappings();
760
+ const contentTypes2 = Object.values(strapi.contentTypes).filter((ct) => ct.kind === "collectionType" && ct.uid.startsWith("api::")).map((ct) => ({
761
+ uid: ct.uid,
762
+ displayName: ct.info?.displayName || ct.uid,
763
+ key: extractResourceKey$1(ct.uid)
764
+ }));
765
+ for (const ct of contentTypes2) {
766
+ if (excludedResources.includes(ct.uid)) continue;
767
+ const mappedFields = resourceAttrMappings[ct.uid] || [];
768
+ const attributes = this.buildAttributeSchema(ct.uid, mappedFields);
769
+ try {
770
+ await permit.api.resources.get(ct.key);
771
+ await permit.api.resources.update(ct.key, {
772
+ name: ct.displayName,
773
+ actions: RESOURCE_ACTIONS,
774
+ attributes
775
+ });
776
+ debugLog(strapi, `[permit-strapi] Updated resource: ${ct.key}`);
777
+ } catch {
778
+ try {
779
+ await permit.api.resources.create({
780
+ key: ct.key,
781
+ name: ct.displayName,
782
+ actions: RESOURCE_ACTIONS,
783
+ attributes
784
+ });
785
+ debugLog(strapi, `[permit-strapi] Created resource: ${ct.key}`);
786
+ } catch (createError) {
787
+ strapi.log.error(`[permit-strapi] Failed to sync resource ${ct.key}: ${createError.message}`);
788
+ }
789
+ }
790
+ }
791
+ },
792
+ async saveExcludedResources(excludedResources) {
793
+ const oldExcluded = await this.getExcludedResources();
794
+ const store = await this.getStore();
795
+ await store.set({ key: "excludedResources", value: excludedResources });
796
+ const permit = strapi.permit;
797
+ if (!permit) return;
798
+ const resourceAttrMappings = await this.getResourceAttributeMappings();
799
+ const newlyExcluded = excludedResources.filter((uid) => !oldExcluded.includes(uid));
800
+ for (const uid of newlyExcluded) {
801
+ const key = extractResourceKey$1(uid);
802
+ try {
803
+ await permit.api.resources.delete(key);
804
+ debugLog(strapi, `[permit-strapi] Deleted resource: ${key}`);
805
+ } catch (error) {
806
+ strapi.log.warn(`[permit-strapi] Could not delete resource ${key}: ${error.message}`);
807
+ }
808
+ }
809
+ const newlyProtected = oldExcluded.filter((uid) => !excludedResources.includes(uid));
810
+ for (const uid of newlyProtected) {
811
+ const ct = strapi.contentTypes[uid];
812
+ if (!ct) continue;
813
+ const key = extractResourceKey$1(uid);
814
+ const displayName = ct.info?.displayName || key;
815
+ const mappedFields = resourceAttrMappings[uid] || [];
816
+ const attributes = this.buildAttributeSchema(uid, mappedFields);
817
+ try {
818
+ await permit.api.resources.get(key);
819
+ await permit.api.resources.update(key, {
820
+ name: displayName,
821
+ actions: RESOURCE_ACTIONS,
822
+ attributes
823
+ });
824
+ debugLog(strapi, `[permit-strapi] Updated resource: ${key}`);
825
+ } catch {
826
+ try {
827
+ await permit.api.resources.create({
828
+ key,
829
+ name: displayName,
830
+ actions: RESOURCE_ACTIONS,
831
+ attributes
832
+ });
833
+ debugLog(strapi, `[permit-strapi] Created resource: ${key}`);
834
+ } catch (createError) {
835
+ strapi.log.error(`[permit-strapi] Failed to create resource ${key}: ${createError.message}`);
836
+ }
837
+ }
838
+ }
839
+ },
840
+ /**
841
+ * Validates the API key against Permit.io, saves the config, reinitializes
842
+ * the shared Permit instance, and syncs all non-excluded resources.
843
+ */
844
+ async validateAndSave({ apiKey, pdpUrl }) {
845
+ const permit = new Permit({ token: apiKey, pdp: pdpUrl });
846
+ try {
847
+ await permit.api.projects.list();
848
+ } catch (error) {
849
+ strapi.log.error(`[permit-strapi] API key validation failed: ${error.message}`);
850
+ throw new Error("Invalid API key. Please check your Permit.io credentials.");
851
+ }
852
+ await this.saveConfig({ apiKey, pdpUrl });
853
+ strapi.permit = new Permit({ token: apiKey, pdp: pdpUrl });
854
+ const excludedResources = await this.getExcludedResources();
855
+ await this.syncResourcesToPermit(excludedResources);
856
+ return { success: true };
857
+ }
858
+ });
859
+ const getRoleKey = (role) => role.type || role.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
860
+ const rolesService = ({ strapi }) => ({
861
+ async getUPRoles() {
862
+ const roles = await strapi.db.query("plugin::users-permissions.role").findMany({});
863
+ return roles.filter((role) => role.type !== "public");
864
+ },
865
+ /** Upserts a single UP role to Permit.io. */
866
+ async createOrUpdatePermitRole(role) {
867
+ const permit = strapi.permit;
868
+ if (!permit || role.type === "public") return;
869
+ const key = getRoleKey(role);
870
+ const name = role.name;
871
+ try {
872
+ await permit.api.roles.get(key);
873
+ await permit.api.roles.update(key, { name });
874
+ debugLog(strapi, `[permit-strapi] Updated role: ${key}`);
875
+ } catch {
876
+ try {
877
+ await permit.api.roles.create({ key, name });
878
+ debugLog(strapi, `[permit-strapi] Created role: ${key}`);
879
+ } catch (createError) {
880
+ strapi.log.error(
881
+ `[permit-strapi] Failed to sync role ${key}: ${createError.message}`
882
+ );
883
+ }
884
+ }
885
+ },
886
+ async deletePermitRole(role) {
887
+ const permit = strapi.permit;
888
+ if (!permit || role.type === "public") return;
889
+ const key = getRoleKey(role);
890
+ try {
891
+ await permit.api.roles.delete(key);
892
+ debugLog(strapi, `[permit-strapi] Deleted role: ${key}`);
893
+ } catch (error) {
894
+ strapi.log.warn(
895
+ `[permit-strapi] Could not delete role ${key}: ${error.message}`
896
+ );
897
+ }
898
+ },
899
+ /** Upserts all non-public UP roles to Permit.io. Called on bootstrap. */
900
+ async syncRolesToPermit() {
901
+ const permit = strapi.permit;
902
+ if (!permit) return;
903
+ const roles = await this.getUPRoles();
904
+ for (const role of roles) {
905
+ await this.createOrUpdatePermitRole(role);
906
+ }
907
+ }
908
+ });
909
+ const DEFAULT_TENANT$1 = "default";
910
+ const usersService = ({ strapi }) => ({
911
+ async getUserWithRole(userId) {
912
+ const mappings = await strapi.plugin("permit-strapi").service("config").getUserAttributeMappings();
913
+ return strapi.db.query("plugin::users-permissions.user").findOne({
914
+ where: { id: userId },
915
+ populate: ["role", ...mappings]
916
+ });
917
+ },
918
+ /** Returns an object of mapped attribute values from a user record. */
919
+ async extractUserAttributes(user) {
920
+ const mappings = await strapi.plugin("permit-strapi").service("config").getUserAttributeMappings();
921
+ if (!mappings || mappings.length === 0) return {};
922
+ const attributes = {};
923
+ for (const field of mappings) {
924
+ const value = user[field];
925
+ if (value === void 0 || value === null) continue;
926
+ if (typeof value === "object" && !Array.isArray(value) && value.id !== void 0) {
927
+ attributes[`${field}Id`] = value.id;
928
+ } else {
929
+ attributes[field] = value;
930
+ }
931
+ }
932
+ return attributes;
933
+ },
934
+ /**
935
+ * Replaces the user's current Permit.io role assignments with the given role.
936
+ * Unassigns all existing roles first to prevent stale accumulation.
937
+ */
938
+ async syncUserRole(key, roleType) {
939
+ const permit = strapi.permit;
940
+ const current = await permit.api.users.getAssignedRoles({
941
+ user: key,
942
+ tenant: DEFAULT_TENANT$1
943
+ });
944
+ await Promise.all(
945
+ current.map(
946
+ (assignment) => permit.api.users.unassignRole({
947
+ user: key,
948
+ role: assignment.role,
949
+ tenant: DEFAULT_TENANT$1
950
+ })
951
+ )
952
+ );
953
+ await permit.api.users.assignRole({
954
+ user: key,
955
+ role: roleType,
956
+ tenant: DEFAULT_TENANT$1
957
+ });
958
+ debugLog(strapi, `[permit-strapi] Assigned role "${roleType}" to user ${key}`);
959
+ },
960
+ /** Syncs a user to Permit.io and assigns their current UP role. */
961
+ async syncUserToPermit(user) {
962
+ const permit = strapi.permit;
963
+ if (!permit) return;
964
+ const fullUser = await this.getUserWithRole(user.id);
965
+ if (!fullUser) return;
966
+ const key = `strapi-${fullUser.id}`;
967
+ const attributes = await this.extractUserAttributes(fullUser);
968
+ attributes.strapiId = fullUser.id;
969
+ try {
970
+ await permit.api.users.sync({
971
+ key,
972
+ email: fullUser.email,
973
+ first_name: fullUser.username,
974
+ attributes
975
+ });
976
+ debugLog(strapi, `[permit-strapi] Synced user: ${key}`);
977
+ } catch (error) {
978
+ strapi.log.error(`[permit-strapi] Failed to sync user ${key}: ${error.message}`);
979
+ return;
980
+ }
981
+ const roleType = fullUser.role?.type;
982
+ if (!roleType || roleType === "public") return;
983
+ try {
984
+ await this.syncUserRole(key, roleType);
985
+ } catch (error) {
986
+ strapi.log.error(`[permit-strapi] Failed to sync role for user ${key}: ${error.message}`);
987
+ }
988
+ },
989
+ /** Bulk syncs all existing UP users to Permit.io. */
990
+ async syncAllUsers() {
991
+ const permit = strapi.permit;
992
+ if (!permit) throw new Error("Permit.io client not initialized");
993
+ const allUsers = await strapi.db.query("plugin::users-permissions.user").findMany({ populate: ["role"] });
994
+ let synced = 0;
995
+ let failed = 0;
996
+ for (const user of allUsers) {
997
+ try {
998
+ await this.syncUserToPermit(user);
999
+ synced++;
1000
+ } catch {
1001
+ failed++;
1002
+ }
1003
+ }
1004
+ debugLog(strapi, `[permit-strapi] Bulk user sync: ${synced} synced, ${failed} failed`);
1005
+ return { synced, failed, total: allUsers.length };
1006
+ },
1007
+ async deletePermitUser(user) {
1008
+ const permit = strapi.permit;
1009
+ if (!permit) return;
1010
+ const key = `strapi-${user.id}`;
1011
+ try {
1012
+ await permit.api.users.delete(key);
1013
+ debugLog(strapi, `[permit-strapi] Deleted user: ${key}`);
1014
+ } catch (error) {
1015
+ strapi.log.warn(`[permit-strapi] Could not delete user ${key}: ${error.message}`);
1016
+ }
1017
+ }
1018
+ });
1019
+ const DEFAULT_TENANT = "default";
1020
+ const extractResourceKey = (uid) => uid.split("::")[1]?.split(".")[0] ?? uid;
1021
+ const instancesService = ({ strapi }) => ({
1022
+ async createResourceInstance(uid, documentId) {
1023
+ const permit = strapi.permit;
1024
+ if (!permit) return;
1025
+ const resourceKey = extractResourceKey(uid);
1026
+ try {
1027
+ await permit.api.resourceInstances.create({
1028
+ resource: resourceKey,
1029
+ key: documentId,
1030
+ tenant: DEFAULT_TENANT
1031
+ });
1032
+ debugLog(strapi, `[permit-strapi] Created resource instance: ${resourceKey}:${documentId}`);
1033
+ } catch (error) {
1034
+ strapi.log.warn(`[permit-strapi] Could not create resource instance ${resourceKey}:${documentId}: ${error.message}`);
1035
+ }
1036
+ },
1037
+ async deleteResourceInstance(uid, documentId) {
1038
+ const permit = strapi.permit;
1039
+ if (!permit) return;
1040
+ const resourceKey = extractResourceKey(uid);
1041
+ try {
1042
+ await permit.api.resourceInstances.delete(`${resourceKey}:${documentId}`);
1043
+ debugLog(strapi, `[permit-strapi] Deleted resource instance: ${resourceKey}:${documentId}`);
1044
+ } catch (error) {
1045
+ strapi.log.warn(`[permit-strapi] Could not delete resource instance ${resourceKey}:${documentId}: ${error.message}`);
1046
+ }
1047
+ },
1048
+ /** Assigns an instance role to a user on a specific resource instance. */
1049
+ async assignInstanceRole(userKey, role, uid, documentId) {
1050
+ const permit = strapi.permit;
1051
+ if (!permit) return;
1052
+ const resourceKey = extractResourceKey(uid);
1053
+ try {
1054
+ await permit.api.roleAssignments.assign({
1055
+ user: userKey,
1056
+ role,
1057
+ resource_instance: `${resourceKey}:${documentId}`,
1058
+ tenant: DEFAULT_TENANT
1059
+ });
1060
+ debugLog(strapi, `[permit-strapi] Assigned role "${role}" to ${userKey} on ${resourceKey}:${documentId}`);
1061
+ } catch (error) {
1062
+ strapi.log.warn(`[permit-strapi] Could not assign instance role for ${userKey} on ${resourceKey}:${documentId}: ${error.message}`);
1063
+ }
1064
+ },
1065
+ /** Bulk syncs all existing records for a content type as Permit.io resource instances. */
1066
+ async syncAllInstances(uid) {
1067
+ const permit = strapi.permit;
1068
+ if (!permit) throw new Error("Permit.io client not initialized");
1069
+ const resourceKey = extractResourceKey(uid);
1070
+ const records = await strapi.db.query(uid).findMany({});
1071
+ let synced = 0;
1072
+ let failed = 0;
1073
+ for (const record of records) {
1074
+ if (!record.documentId) continue;
1075
+ try {
1076
+ await permit.api.resourceInstances.create({
1077
+ resource: resourceKey,
1078
+ key: record.documentId,
1079
+ tenant: DEFAULT_TENANT
1080
+ });
1081
+ synced++;
1082
+ } catch {
1083
+ failed++;
1084
+ }
1085
+ }
1086
+ debugLog(strapi, `[permit-strapi] Bulk instance sync for ${resourceKey}: ${synced} synced, ${failed} failed`);
1087
+ return { synced, failed, total: records.length };
1088
+ }
1089
+ });
1090
+ const services = {
1091
+ service,
1092
+ config: configService,
1093
+ roles: rolesService,
1094
+ users: usersService,
1095
+ instances: instancesService
1096
+ };
1097
+ const index = {
1098
+ register,
1099
+ bootstrap,
1100
+ destroy,
1101
+ config,
1102
+ controllers,
1103
+ routes,
1104
+ services,
1105
+ contentTypes,
1106
+ policies,
1107
+ middlewares
1108
+ };
1109
+ export {
1110
+ index as default
1111
+ };