@endora-commerce/mod-admin-roles 0.100.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/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/admin/index.d.ts +47 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +27 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/backend/demo/reset.d.ts +20 -0
- package/dist/backend/demo/reset.d.ts.map +1 -0
- package/dist/backend/demo/reset.js +13 -0
- package/dist/backend/demo/reset.js.map +1 -0
- package/dist/backend/demo/rows.d.ts +64 -0
- package/dist/backend/demo/rows.d.ts.map +1 -0
- package/dist/backend/demo/rows.js +74 -0
- package/dist/backend/demo/rows.js.map +1 -0
- package/dist/backend/demo/seed.d.ts +20 -0
- package/dist/backend/demo/seed.d.ts.map +1 -0
- package/dist/backend/demo/seed.js +29 -0
- package/dist/backend/demo/seed.js.map +1 -0
- package/dist/backend/entities/admin-role.entity.d.ts +20 -0
- package/dist/backend/entities/admin-role.entity.d.ts.map +1 -0
- package/dist/backend/entities/admin-role.entity.js +65 -0
- package/dist/backend/entities/admin-role.entity.js.map +1 -0
- package/dist/backend/index.d.ts +96 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +114 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/permission-inventory.d.ts +117 -0
- package/dist/backend/permission-inventory.d.ts.map +1 -0
- package/dist/backend/permission-inventory.js +585 -0
- package/dist/backend/permission-inventory.js.map +1 -0
- package/dist/backend/services/admin-role-ports.d.ts +39 -0
- package/dist/backend/services/admin-role-ports.d.ts.map +1 -0
- package/dist/backend/services/admin-role-ports.js +72 -0
- package/dist/backend/services/admin-role-ports.js.map +1 -0
- package/dist/backend/services/admin-role-service.d.ts +77 -0
- package/dist/backend/services/admin-role-service.d.ts.map +1 -0
- package/dist/backend/services/admin-role-service.js +219 -0
- package/dist/backend/services/admin-role-service.js.map +1 -0
- package/dist/backend/services/permission-catalogue.service.d.ts +130 -0
- package/dist/backend/services/permission-catalogue.service.d.ts.map +1 -0
- package/dist/backend/services/permission-catalogue.service.js +175 -0
- package/dist/backend/services/permission-catalogue.service.js.map +1 -0
- package/dist/backend/services/permission-service.d.ts +24 -0
- package/dist/backend/services/permission-service.d.ts.map +1 -0
- package/dist/backend/services/permission-service.js +40 -0
- package/dist/backend/services/permission-service.js.map +1 -0
- package/dist/manifest.d.ts +169 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +192 -0
- package/dist/manifest.js.map +1 -0
- package/docs/admin_roles.md +38 -0
- package/i18n/en.json +10 -0
- package/i18n/pl.json +10 -0
- package/package.json +73 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { AdminRole } from '../entities/admin-role.entity.js';
|
|
2
|
+
import { listSystemRoleCodes, registerSystemRoleCode, } from './admin-role-service.js';
|
|
3
|
+
/**
|
|
4
|
+
* The published face of `admin_roles` (feature 075, Phase P).
|
|
5
|
+
*
|
|
6
|
+
* Fourteen inbound sites: `admin_users` reads a role beside its user and
|
|
7
|
+
* enforces `requiresTwoFactor` at login, `admin_actions` filters the command
|
|
8
|
+
* palette by a user's permissions, `blog` protects the role it seeds, and the
|
|
9
|
+
* dev seed creates one.
|
|
10
|
+
*
|
|
11
|
+
* `findByCode` is the method with no direct predecessor. `admin_users`'
|
|
12
|
+
* service and the dev seed both look a role up by code with
|
|
13
|
+
* `em.findOne(AdminRole, { code })`, which is the read it replaces.
|
|
14
|
+
*/
|
|
15
|
+
export class AdminRoleReadService {
|
|
16
|
+
emFactory;
|
|
17
|
+
constructor(emFactory) {
|
|
18
|
+
this.emFactory = emFactory;
|
|
19
|
+
}
|
|
20
|
+
async findByCode(code) {
|
|
21
|
+
const role = await this.emFactory().findOne(AdminRole, { code });
|
|
22
|
+
return role ? toAdminRoleRecord(role) : null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The full role port, adapting `AdminRoleService` and adding the by-code
|
|
27
|
+
* lookup. The service arrives as a getter so the adapter resolves it per call
|
|
28
|
+
* rather than capturing it.
|
|
29
|
+
*/
|
|
30
|
+
export function createAdminRolePort(emFactory, getService) {
|
|
31
|
+
const read = new AdminRoleReadService(emFactory);
|
|
32
|
+
return {
|
|
33
|
+
async list() {
|
|
34
|
+
return (await getService().list()).map(toAdminRoleRecord);
|
|
35
|
+
},
|
|
36
|
+
async getById(id) {
|
|
37
|
+
return toAdminRoleRecord(await getService().getById(id));
|
|
38
|
+
},
|
|
39
|
+
findByCode: (code) => read.findByCode(code),
|
|
40
|
+
async upsertByCode(input) {
|
|
41
|
+
return toAdminRoleRecord(await getService().upsertByCode(input));
|
|
42
|
+
},
|
|
43
|
+
remove: (id) => getService().remove(id),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The contribution seam for deletion-protected role codes.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately a plain object over the module-level set rather than anything
|
|
50
|
+
* gated: `blog` registers its seeded code from a boot hook, and a gate would
|
|
51
|
+
* both throw during composition and — worse — let an operator delete a
|
|
52
|
+
* protected role by switching its owner off for a moment. See the port's
|
|
53
|
+
* contract for the full reasoning.
|
|
54
|
+
*/
|
|
55
|
+
export function createSystemRoleCodePort() {
|
|
56
|
+
return {
|
|
57
|
+
register: (code) => registerSystemRoleCode(code),
|
|
58
|
+
list: () => listSystemRoleCodes(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function toAdminRoleRecord(role) {
|
|
62
|
+
return {
|
|
63
|
+
id: role.id,
|
|
64
|
+
code: role.code,
|
|
65
|
+
name: role.name,
|
|
66
|
+
permissions: role.permissions ?? [],
|
|
67
|
+
requiresTwoFactor: role.requiresTwoFactor,
|
|
68
|
+
createdAt: role.createdAt,
|
|
69
|
+
updatedAt: role.updatedAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=admin-role-ports.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-role-ports.js","sourceRoot":"","sources":["../../../src/backend/services/admin-role-ports.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAC7D,OAAO,EAEL,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,oBAAoB;IACF;IAA7B,YAA6B,SAA8B;QAA9B,cAAS,GAAT,SAAS,CAAqB;IAAG,CAAC;IAE/D,KAAK,CAAC,UAAU,CAAC,IAAY;QAC3B,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QACjE,OAAO,IAAI,CAAC,CAAC,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC/C,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,SAA8B,EAC9B,UAAkC;IAElC,MAAM,IAAI,GAAG,IAAI,oBAAoB,CAAC,SAAS,CAAC,CAAC;IACjD,OAAO;QACL,KAAK,CAAC,IAAI;YACR,OAAO,CAAC,MAAM,UAAU,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;QAC5D,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,EAAE;YACd,OAAO,iBAAiB,CAAC,MAAM,UAAU,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;QAC3D,CAAC;QACD,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAC3C,KAAK,CAAC,YAAY,CAAC,KAAK;YACtB,OAAO,iBAAiB,CAAC,MAAM,UAAU,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC;QACnE,CAAC;QACD,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;KACxC,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,wBAAwB;IACtC,OAAO;QACL,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,sBAAsB,CAAC,IAAI,CAAC;QAChD,IAAI,EAAE,GAAG,EAAE,CAAC,mBAAmB,EAAE;KAClC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAe;IAC/C,OAAO;QACL,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,EAAE;QACnC,iBAAiB,EAAE,IAAI,CAAC,iBAAiB;QACzC,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,SAAS,EAAE,IAAI,CAAC,SAAS;KAC1B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import { type AdminUserReadPort } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import { AdminRole } from '../entities/admin-role.entity.js';
|
|
6
|
+
import type { PermissionCatalogueService } from './permission-catalogue.service.js';
|
|
7
|
+
export interface UpsertAdminRoleInput {
|
|
8
|
+
code: string;
|
|
9
|
+
name: string;
|
|
10
|
+
permissions: string[];
|
|
11
|
+
requiresTwoFactor?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Module-level registration for seeded role codes that should be
|
|
15
|
+
* deletion-protected. Idempotent. Exposed as a top-level function so
|
|
16
|
+
* modules can call it from their plugin without holding an
|
|
17
|
+
* AdminRoleService instance.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerSystemRoleCode(code: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* The protected codes, in registration order. Published through
|
|
22
|
+
* `SystemRoleCodePort` (feature 075, Phase P) so a contributor can read back
|
|
23
|
+
* what the platform is protecting without reaching into this file.
|
|
24
|
+
*/
|
|
25
|
+
export declare function listSystemRoleCodes(): readonly string[];
|
|
26
|
+
/** Test helper — clears the protected-codes registry between suites. */
|
|
27
|
+
export declare function _resetSystemRoleCodesForTests(): void;
|
|
28
|
+
/**
|
|
29
|
+
* The refusal a role deletion earns from its assignees, or `null` when it has
|
|
30
|
+
* none (issue #168).
|
|
31
|
+
*
|
|
32
|
+
* Exported and pure so the sentence's `{placeholders}` can be checked against
|
|
33
|
+
* the `details` a real refusal carries — the agreement issue #161 found nothing
|
|
34
|
+
* was checking, one code family over. `check:error-translations` sees that a
|
|
35
|
+
* code *has* a sentence; only a test that renders one can see that the sentence
|
|
36
|
+
* has anything to fill it.
|
|
37
|
+
*
|
|
38
|
+
* The token in `details.code` chooses the sentence, and the count fills it.
|
|
39
|
+
* Both populations refuse, and they are separated because the remedy differs:
|
|
40
|
+
* reassign a live admin; restore-and-reassign or purge a deleted one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function roleInUseRefusal(live: number, deleted: number): HttpError | null;
|
|
43
|
+
/**
|
|
44
|
+
* The refusal a seeded role's deletion earns, with the role's own code on it.
|
|
45
|
+
*
|
|
46
|
+
* Exported and pure for the reason {@link roleInUseRefusal} is: the sentence
|
|
47
|
+
* that replaces this message carries a `{role}` placeholder, and only a test
|
|
48
|
+
* that renders a **real** refusal against the bundle can see that the
|
|
49
|
+
* placeholder has something to fill it (issue #161, and !1181's finding that a
|
|
50
|
+
* translated sentence with no placeholder loses an interpolated value silently).
|
|
51
|
+
*
|
|
52
|
+
* `details.role`, never `details.code`: the latter is the refusal token the
|
|
53
|
+
* envelope keys `errors.<CODE>.<token>` on, so putting a role code there would
|
|
54
|
+
* send it looking for a sentence named after the role.
|
|
55
|
+
*/
|
|
56
|
+
export declare function protectedRoleRefusal(code: string): HttpError;
|
|
57
|
+
export declare class AdminRoleService {
|
|
58
|
+
#private;
|
|
59
|
+
private readonly emFactory;
|
|
60
|
+
private readonly permissionCatalogue;
|
|
61
|
+
/** `adminUserReadPort` — who still holds a role (feature 075, Phase C). */
|
|
62
|
+
private readonly adminUsers;
|
|
63
|
+
private readonly auditLog?;
|
|
64
|
+
constructor(emFactory: () => EntityManager, permissionCatalogue: PermissionCatalogueService,
|
|
65
|
+
/** `adminUserReadPort` — who still holds a role (feature 075, Phase C). */
|
|
66
|
+
adminUsers: AdminUserReadPort, auditLog?: AuditPort | undefined);
|
|
67
|
+
/**
|
|
68
|
+
* Register a role code as system-protected. Called by modules at
|
|
69
|
+
* plugin startup for their seeded roles. Idempotent.
|
|
70
|
+
*/
|
|
71
|
+
registerSystemRoleCode(code: string): void;
|
|
72
|
+
list(): Promise<AdminRole[]>;
|
|
73
|
+
getById(id: string): Promise<AdminRole>;
|
|
74
|
+
upsertByCode(input: UpsertAdminRoleInput): Promise<AdminRole>;
|
|
75
|
+
remove(id: string): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=admin-role-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-role-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/admin-role-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,EAAe,KAAK,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AACjF,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AAE3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAC7D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,mCAAmC,CAAC;AA0BpF,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAWD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,SAAS,MAAM,EAAE,CAEvD;AAED,wEAAwE;AACxE,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAmBhF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAO5D;AAED,qBAAa,gBAAgB;;IAEzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,mBAAmB;IACpC,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAJT,SAAS,EAAE,MAAM,aAAa,EAC9B,mBAAmB,EAAE,0BAA0B;IAChE,2EAA2E;IAC1D,UAAU,EAAE,iBAAiB,EAC7B,QAAQ,CAAC,EAAE,SAAS,YAAA;IASvC;;;OAGG;IACH,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIpC,IAAI,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;IAK5B,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAOvC,YAAY,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,SAAS,CAAC;IAwC7D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAoDxC"}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core';
|
|
2
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
3
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
4
|
+
import { recordAuditFromContext } from '@endora-commerce/platform/commands';
|
|
5
|
+
import { AdminRole } from '../entities/admin-role.entity.js';
|
|
6
|
+
/**
|
|
7
|
+
* AdminRoleService (T193). CRUD over AdminRole rows. Invariants:
|
|
8
|
+
*
|
|
9
|
+
* - permissions referenced in `permissions` MUST be in the merged assignable
|
|
10
|
+
* catalogue (`PermissionCatalogueService`), OR be the wildcard `*` which
|
|
11
|
+
* grants full access. When `*` is present the permission set is normalised
|
|
12
|
+
* to exactly `['*']` (a role is either "full access" or an explicit list).
|
|
13
|
+
* - the bootstrap `platform_admin` role is locked to `['*']`: it always has
|
|
14
|
+
* full access and its permission set cannot be downgraded from the UI.
|
|
15
|
+
* - a Role with assigned AdminUsers cannot be deleted (409); reassign or
|
|
16
|
+
* deactivate the users first. A **soft-deleted** admin still counts
|
|
17
|
+
* (issue #168): restoring the account restores its role, so the assignment
|
|
18
|
+
* survives the deletion and the role is still in use.
|
|
19
|
+
*/
|
|
20
|
+
/** Wildcard permission — grants access to every gated admin route. */
|
|
21
|
+
const WILDCARD = '*';
|
|
22
|
+
/**
|
|
23
|
+
* The bootstrap super-admin role. Its permission set is forced to the wildcard
|
|
24
|
+
* on every upsert so it can never be locked out of the panel.
|
|
25
|
+
*/
|
|
26
|
+
const PLATFORM_ADMIN_CODE = 'platform_admin';
|
|
27
|
+
/**
|
|
28
|
+
* Codes of seeded roles that other modules register as
|
|
29
|
+
* deletion-protected. Modules call `registerSystemRoleCode(...)` from
|
|
30
|
+
* their plugin to add their seeded codes to this set; once registered,
|
|
31
|
+
* `remove` refuses to delete a role whose `code` is in the set with a
|
|
32
|
+
* 409 ADMIN_ROLE_PROTECTED envelope (feature 016 / FR-025).
|
|
33
|
+
*/
|
|
34
|
+
const SYSTEM_ROLE_CODES = new Set();
|
|
35
|
+
/**
|
|
36
|
+
* Module-level registration for seeded role codes that should be
|
|
37
|
+
* deletion-protected. Idempotent. Exposed as a top-level function so
|
|
38
|
+
* modules can call it from their plugin without holding an
|
|
39
|
+
* AdminRoleService instance.
|
|
40
|
+
*/
|
|
41
|
+
export function registerSystemRoleCode(code) {
|
|
42
|
+
SYSTEM_ROLE_CODES.add(code);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The protected codes, in registration order. Published through
|
|
46
|
+
* `SystemRoleCodePort` (feature 075, Phase P) so a contributor can read back
|
|
47
|
+
* what the platform is protecting without reaching into this file.
|
|
48
|
+
*/
|
|
49
|
+
export function listSystemRoleCodes() {
|
|
50
|
+
return [...SYSTEM_ROLE_CODES];
|
|
51
|
+
}
|
|
52
|
+
/** Test helper — clears the protected-codes registry between suites. */
|
|
53
|
+
export function _resetSystemRoleCodesForTests() {
|
|
54
|
+
SYSTEM_ROLE_CODES.clear();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The refusal a role deletion earns from its assignees, or `null` when it has
|
|
58
|
+
* none (issue #168).
|
|
59
|
+
*
|
|
60
|
+
* Exported and pure so the sentence's `{placeholders}` can be checked against
|
|
61
|
+
* the `details` a real refusal carries — the agreement issue #161 found nothing
|
|
62
|
+
* was checking, one code family over. `check:error-translations` sees that a
|
|
63
|
+
* code *has* a sentence; only a test that renders one can see that the sentence
|
|
64
|
+
* has anything to fill it.
|
|
65
|
+
*
|
|
66
|
+
* The token in `details.code` chooses the sentence, and the count fills it.
|
|
67
|
+
* Both populations refuse, and they are separated because the remedy differs:
|
|
68
|
+
* reassign a live admin; restore-and-reassign or purge a deleted one.
|
|
69
|
+
*/
|
|
70
|
+
export function roleInUseRefusal(live, deleted) {
|
|
71
|
+
if (live > 0) {
|
|
72
|
+
return new HttpError(409, ERROR_CODES.ADMIN_ROLE_IN_USE, `Cannot delete role: ${live} admin user(s) still assigned.`, { code: 'assigned', assigned: live });
|
|
73
|
+
}
|
|
74
|
+
if (deleted > 0) {
|
|
75
|
+
return new HttpError(409, ERROR_CODES.ADMIN_ROLE_IN_USE, `Cannot delete role: ${deleted} deleted admin account(s) still hold it, ` +
|
|
76
|
+
`and restoring one restores the assignment.`, { code: 'assigned_to_deleted', deleted });
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The refusal a seeded role's deletion earns, with the role's own code on it.
|
|
82
|
+
*
|
|
83
|
+
* Exported and pure for the reason {@link roleInUseRefusal} is: the sentence
|
|
84
|
+
* that replaces this message carries a `{role}` placeholder, and only a test
|
|
85
|
+
* that renders a **real** refusal against the bundle can see that the
|
|
86
|
+
* placeholder has something to fill it (issue #161, and !1181's finding that a
|
|
87
|
+
* translated sentence with no placeholder loses an interpolated value silently).
|
|
88
|
+
*
|
|
89
|
+
* `details.role`, never `details.code`: the latter is the refusal token the
|
|
90
|
+
* envelope keys `errors.<CODE>.<token>` on, so putting a role code there would
|
|
91
|
+
* send it looking for a sentence named after the role.
|
|
92
|
+
*/
|
|
93
|
+
export function protectedRoleRefusal(code) {
|
|
94
|
+
return new HttpError(409, ERROR_CODES.ADMIN_ROLE_PROTECTED, `Cannot delete the system-protected role "${code}". Modules' seeded roles are immutable.`, { role: code });
|
|
95
|
+
}
|
|
96
|
+
export class AdminRoleService {
|
|
97
|
+
emFactory;
|
|
98
|
+
permissionCatalogue;
|
|
99
|
+
adminUsers;
|
|
100
|
+
auditLog;
|
|
101
|
+
constructor(emFactory, permissionCatalogue,
|
|
102
|
+
/** `adminUserReadPort` — who still holds a role (feature 075, Phase C). */
|
|
103
|
+
adminUsers, auditLog) {
|
|
104
|
+
this.emFactory = emFactory;
|
|
105
|
+
this.permissionCatalogue = permissionCatalogue;
|
|
106
|
+
this.adminUsers = adminUsers;
|
|
107
|
+
this.auditLog = auditLog;
|
|
108
|
+
}
|
|
109
|
+
#audit(em, action, objectId, stateBefore, stateAfter) {
|
|
110
|
+
if (this.auditLog) {
|
|
111
|
+
recordAuditFromContext(this.auditLog, em, { action, objectType: 'admin_role', objectId, stateBefore, stateAfter });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Register a role code as system-protected. Called by modules at
|
|
116
|
+
* plugin startup for their seeded roles. Idempotent.
|
|
117
|
+
*/
|
|
118
|
+
registerSystemRoleCode(code) {
|
|
119
|
+
SYSTEM_ROLE_CODES.add(code);
|
|
120
|
+
}
|
|
121
|
+
async list() {
|
|
122
|
+
const em = this.emFactory();
|
|
123
|
+
return em.find(AdminRole, {}, { orderBy: { name: 'asc' } });
|
|
124
|
+
}
|
|
125
|
+
async getById(id) {
|
|
126
|
+
const em = this.emFactory();
|
|
127
|
+
const row = await em.findOne(AdminRole, { id });
|
|
128
|
+
if (!row)
|
|
129
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Admin role not found.');
|
|
130
|
+
return row;
|
|
131
|
+
}
|
|
132
|
+
async upsertByCode(input) {
|
|
133
|
+
// The platform_admin role is always full-access; ignore any narrower
|
|
134
|
+
// payload so the bootstrap super-admin can never be downgraded. Every
|
|
135
|
+
// other role may opt into the wildcard or carry an explicit list.
|
|
136
|
+
const permissions = input.code === PLATFORM_ADMIN_CODE
|
|
137
|
+
? [WILDCARD]
|
|
138
|
+
: this.#normalizePermissions(input.permissions);
|
|
139
|
+
const em = this.emFactory();
|
|
140
|
+
let role = await em.findOne(AdminRole, { code: input.code });
|
|
141
|
+
if (role) {
|
|
142
|
+
role.name = input.name;
|
|
143
|
+
role.permissions = permissions;
|
|
144
|
+
role.requiresTwoFactor = input.requiresTwoFactor ?? role.requiresTwoFactor;
|
|
145
|
+
this.#audit(em, 'admin_role.upsert', role.id, null, { code: role.code, name: role.name });
|
|
146
|
+
await em.flush();
|
|
147
|
+
return role;
|
|
148
|
+
}
|
|
149
|
+
role = em.create(AdminRole, {
|
|
150
|
+
code: input.code,
|
|
151
|
+
name: input.name,
|
|
152
|
+
permissions,
|
|
153
|
+
requiresTwoFactor: input.requiresTwoFactor ?? false,
|
|
154
|
+
});
|
|
155
|
+
this.#audit(em, 'admin_role.upsert', role.id, null, { code: role.code, name: role.name });
|
|
156
|
+
try {
|
|
157
|
+
await em.persistAndFlush(role);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err instanceof UniqueConstraintViolationException) {
|
|
161
|
+
throw new HttpError(409, ERROR_CODES.ADMIN_ROLE_CODE_TAKEN, 'A role with that code already exists.');
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
return role;
|
|
166
|
+
}
|
|
167
|
+
async remove(id) {
|
|
168
|
+
const em = this.emFactory();
|
|
169
|
+
const role = await em.findOne(AdminRole, { id });
|
|
170
|
+
if (!role)
|
|
171
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Admin role not found.');
|
|
172
|
+
if (SYSTEM_ROLE_CODES.has(role.code))
|
|
173
|
+
throw protectedRoleRefusal(role.code);
|
|
174
|
+
// Every assignee a restore returns, live or binned (issue #168).
|
|
175
|
+
//
|
|
176
|
+
// A soft delete does not release the assignment: the row keeps its
|
|
177
|
+
// `admin_role_id`, and restoring the account restores the role with it. So
|
|
178
|
+
// counting live assignees only made the guard pass on a role that was still
|
|
179
|
+
// held — and the delete it let through hit `admin_users_admin_role_fk`
|
|
180
|
+
// (`on delete restrict`) and answered 500. The operator was told the role
|
|
181
|
+
// was unused, confirmed, and got a server error.
|
|
182
|
+
//
|
|
183
|
+
// The two populations are still counted apart, because the remedy differs:
|
|
184
|
+
// reassign a live admin, purge or restore-and-reassign a deleted one. Each
|
|
185
|
+
// refusal carries its token in `details.code` — that is what keys the
|
|
186
|
+
// sentence — and its count, which the sentence interpolates.
|
|
187
|
+
const assignees = await this.adminUsers.listByRoleId(role.id);
|
|
188
|
+
const live = assignees.filter((admin) => admin.deletedAt === null).length;
|
|
189
|
+
const refusal = roleInUseRefusal(live, assignees.length - live);
|
|
190
|
+
if (refusal)
|
|
191
|
+
throw refusal;
|
|
192
|
+
this.#audit(em, 'admin_role.delete', role.id, { code: role.code }, null);
|
|
193
|
+
await em.removeAndFlush(role);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Validate an explicit permission list and collapse the wildcard. A list
|
|
197
|
+
* containing `*` is "full access" — it normalises to exactly `['*']` and the
|
|
198
|
+
* catalogue check is skipped. Otherwise every code must exist in the merged
|
|
199
|
+
* catalogue, else a 400 VALIDATION_FAILED is raised.
|
|
200
|
+
*
|
|
201
|
+
* Issue #213 — validated against the **known** vocabulary, not the grantable
|
|
202
|
+
* set. Since the grantable set became presence-filtered, the two differ
|
|
203
|
+
* exactly while a module is off, and validating against the narrower one
|
|
204
|
+
* would refuse to save any role that still holds a switched-off module's
|
|
205
|
+
* code. Off is non-destructive and reversible; a 400 on an unrelated rename
|
|
206
|
+
* is neither. See `PermissionCatalogueService.listKnownCodes`.
|
|
207
|
+
*/
|
|
208
|
+
#normalizePermissions(permissions) {
|
|
209
|
+
if (permissions.includes(WILDCARD))
|
|
210
|
+
return [WILDCARD];
|
|
211
|
+
const known = new Set(this.permissionCatalogue.listKnownCodes());
|
|
212
|
+
const invalid = permissions.filter((p) => !known.has(p));
|
|
213
|
+
if (invalid.length > 0) {
|
|
214
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Unknown permission(s): ${invalid.join(', ')}`);
|
|
215
|
+
}
|
|
216
|
+
return permissions;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=admin-role-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-role-service.js","sourceRoot":"","sources":["../../../src/backend/services/admin-role-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type ModuleManifest, type PermissionCatalogueEntry } from '@endora-commerce/contracts';
|
|
2
|
+
export interface PermissionCatalogueServiceOptions {
|
|
3
|
+
registryEntries: ReadonlyArray<{
|
|
4
|
+
manifest: ModuleManifest;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* **The effective presence of a module — both axes, not one** (issue #213).
|
|
8
|
+
*
|
|
9
|
+
* This used to be `getEnabledModuleIds`, wired by both composition roots to
|
|
10
|
+
* `registryCache.enabledIds()`. That set is the **platform** axis alone: an
|
|
11
|
+
* operator deactivation leaves the id in it, so every one of the 65 modules
|
|
12
|
+
* kept contributing its permission codes to `/admin-roles` after the operator
|
|
13
|
+
* switched it off — the exact conflation Constitution XVII exists to prevent,
|
|
14
|
+
* shipped.
|
|
15
|
+
*
|
|
16
|
+
* The catalogue answers "may an operator grant this today?", and presence is
|
|
17
|
+
* the conjunction of platform availability and operator activation, so this
|
|
18
|
+
* predicate is `effectiveState.isPresent` and nothing narrower. It is spelled
|
|
19
|
+
* out here rather than left to the wiring because the two axes are the thing
|
|
20
|
+
* people conflate, and a predicate named after one of them invites it back.
|
|
21
|
+
*
|
|
22
|
+
* **The two off states are not distinguished on this surface, deliberately.**
|
|
23
|
+
* Principle XVII item 5 asks a platform-*unavailable* module to render as
|
|
24
|
+
* absent or blocked-with-a-reason rather than merely "switched off" — that
|
|
25
|
+
* distinction is about a module's *own* surfaces, where an operator can act on
|
|
26
|
+
* the reason. `/admin-roles` renders a flat list of grantable codes: it has no
|
|
27
|
+
* per-module row to carry a reason, and no action an operator could take from
|
|
28
|
+
* there differs between the two cases. Absent is the whole answer. The screen
|
|
29
|
+
* that owes the distinction is `/platform/modules`, and it renders it.
|
|
30
|
+
*
|
|
31
|
+
* Omitted ⇒ every registered module counts as present. That is the
|
|
32
|
+
* pre-composition path (the CI helper below, unit tests), not a fall-open: a
|
|
33
|
+
* presence read before the registry cache is loaded throws
|
|
34
|
+
* `ModulePresenceNotLoadedError` rather than answering, so an unwired
|
|
35
|
+
* catalogue cannot be mistaken for a loaded one that found nothing.
|
|
36
|
+
*/
|
|
37
|
+
isModulePresent?: (moduleId: string) => boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Merges the core `PERMISSION_CATALOGUE` with the `permissions` arrays of every
|
|
41
|
+
* registered module manifest. Powers `GET /admin/permissions` and role upsert
|
|
42
|
+
* validation (feature 026).
|
|
43
|
+
*
|
|
44
|
+
* **There is no memo, and that is the design** (issue #213). `listAssignable()`
|
|
45
|
+
* cached its result into a field that only an explicit `invalidate()` cleared,
|
|
46
|
+
* and the two things that had to call it — a Redis pub/sub listener in each
|
|
47
|
+
* composition root — were a duplicated wiring step rather than a property of the
|
|
48
|
+
* service. That is the shape of issue #33 (an admin "clear cache" action that
|
|
49
|
+
* missed the per-process LRU) and issue #45 (an invalidation that depended on
|
|
50
|
+
* subscriber registration order) for the third time. Now that the value tracks a
|
|
51
|
+
* Setting an operator flips from the Admin UI at runtime, a per-process memo can
|
|
52
|
+
* only be wrong between the flip and whatever drops it.
|
|
53
|
+
*
|
|
54
|
+
* Nothing is lost by dropping it: every input is already in memory — the
|
|
55
|
+
* manifests are fixed at composition and the presence predicate reads the
|
|
56
|
+
* registry cache, which does its own refreshing — so a call is a map build and a
|
|
57
|
+
* sort over a few hundred rows, on two admin-only paths. There is no ordering
|
|
58
|
+
* trap to test because there is no invalidation to order.
|
|
59
|
+
*/
|
|
60
|
+
export declare class PermissionCatalogueService {
|
|
61
|
+
#private;
|
|
62
|
+
private readonly options;
|
|
63
|
+
private readonly isModulePresent;
|
|
64
|
+
constructor(options: PermissionCatalogueServiceOptions);
|
|
65
|
+
/**
|
|
66
|
+
* The grantable set: what `/admin-roles` renders and what an operator may
|
|
67
|
+
* newly grant. Presence-filtered, so a module that is off contributes nothing
|
|
68
|
+
* (Constitution XVII item 5).
|
|
69
|
+
*/
|
|
70
|
+
listAssignable(): PermissionCatalogueEntry[];
|
|
71
|
+
listAssignableCodes(): string[];
|
|
72
|
+
/**
|
|
73
|
+
* Every code the platform knows, present or not — the **vocabulary**, not the
|
|
74
|
+
* grantable set.
|
|
75
|
+
*
|
|
76
|
+
* Role upsert validates against this rather than against `listAssignable()`,
|
|
77
|
+
* and the difference is what keeps "off is non-destructive and reversible"
|
|
78
|
+
* true. An "Editor" role holds `blog.read`; the operator switches `blog` off
|
|
79
|
+
* and then renames the role. Validating the submitted list against the
|
|
80
|
+
* grantable set would answer 400 `Unknown permission(s): blog.read` — the
|
|
81
|
+
* operator either loses the role or silently drops a grant that must come back
|
|
82
|
+
* when `blog` does. A code of an absent module grants access to nothing anyway:
|
|
83
|
+
* the routes behind it answer 503 `MODULE_DISABLED` at their own seam.
|
|
84
|
+
*
|
|
85
|
+
* This is what the role editor already relies on: `AdminRolesPage` seeds its
|
|
86
|
+
* selection from the **role's** codes and renders a checkbox only for the ones
|
|
87
|
+
* the catalogue offers, so an absent module's grant is carried through the
|
|
88
|
+
* round trip untouched and simply has no checkbox to un-tick while the module
|
|
89
|
+
* is off. Narrowing this method would turn that into a silent revocation.
|
|
90
|
+
*/
|
|
91
|
+
listKnownCodes(): string[];
|
|
92
|
+
/**
|
|
93
|
+
* Every code the platform knows, mapped to the modules whose presence keeps
|
|
94
|
+
* it grantable — {@link MergedRow.owners}, published.
|
|
95
|
+
*
|
|
96
|
+
* D-173's sweep reads it, and it is a method rather than a derivation of its
|
|
97
|
+
* own because the whole question is *"does `/admin-roles` still offer this
|
|
98
|
+
* code when its owner is switched off?"* — a question only the merge this
|
|
99
|
+
* class performs can answer. A second implementation over
|
|
100
|
+
* `PERMISSION_CATALOGUE` plus the manifests would be right on the day it was
|
|
101
|
+
* written and would drift the first time the merge learns a rule: a shared
|
|
102
|
+
* code with two owners, or a catalogue row a manifest re-declares, is exactly
|
|
103
|
+
* where the two would disagree, and exactly the shape the sweep exists for.
|
|
104
|
+
*/
|
|
105
|
+
listOwnersByCode(): Map<string, ReadonlySet<string>>;
|
|
106
|
+
/**
|
|
107
|
+
* The codes `moduleId` contributes, whether or not it is present.
|
|
108
|
+
*
|
|
109
|
+
* Exists for the off-state harness: proving a module's codes leave the
|
|
110
|
+
* grantable set needs to know which codes are its, and deriving that from the
|
|
111
|
+
* `module` display field would silently measure nothing for `_lifecycle`.
|
|
112
|
+
*/
|
|
113
|
+
listOwnedCodes(moduleId: string): string[];
|
|
114
|
+
/**
|
|
115
|
+
* Every declared requirement, as `code -> the codes a role holding it needs`
|
|
116
|
+
* (D-175, feature 080 T057).
|
|
117
|
+
*
|
|
118
|
+
* The **vocabulary's** view, not the grantable set's: the advisory has to
|
|
119
|
+
* survive its target's owner being switched off, or it would go quiet at
|
|
120
|
+
* exactly the moment an operator most needs to know why a screen came back
|
|
121
|
+
* incomplete. Empty requirement sets are omitted, so `size` is the number of
|
|
122
|
+
* codes that declare one.
|
|
123
|
+
*/
|
|
124
|
+
listRequirementsByCode(): Map<string, readonly string[]>;
|
|
125
|
+
}
|
|
126
|
+
/** Test / CI helper — build assignable codes without wiring composition. */
|
|
127
|
+
export declare function listAssignablePermissionCodes(registryEntries: ReadonlyArray<{
|
|
128
|
+
manifest: ModuleManifest;
|
|
129
|
+
}>, presentModuleIds?: readonly string[]): string[];
|
|
130
|
+
//# sourceMappingURL=permission-catalogue.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-catalogue.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/permission-catalogue.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,wBAAwB,EAC9B,MAAM,4BAA4B,CAAC;AAEpC,MAAM,WAAW,iCAAiC;IAChD,eAAe,EAAE,aAAa,CAAC;QAAE,QAAQ,EAAE,cAAc,CAAA;KAAE,CAAC,CAAC;IAC7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;CACjD;AAqBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,0BAA0B;;IAGzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAgC;gBAEnC,OAAO,EAAE,iCAAiC;IAIvE;;;;OAIG;IACH,cAAc,IAAI,wBAAwB,EAAE;IAM5C,mBAAmB,IAAI,MAAM,EAAE;IAI/B;;;;;;;;;;;;;;;;;;OAkBG;IACH,cAAc,IAAI,MAAM,EAAE;IAI1B;;;;;;;;;;;;OAYG;IACH,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAIpD;;;;;;OAMG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE;IAwE1C;;;;;;;;;OASG;IACH,sBAAsB,IAAI,GAAG,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CAOzD;AAED,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAC3C,eAAe,EAAE,aAAa,CAAC;IAAE,QAAQ,EAAE,cAAc,CAAA;CAAE,CAAC,EAC5D,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,MAAM,EAAE,CAMV"}
|