@tailor-platform/erp-kit 0.0.1 → 0.1.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/README.md +196 -28
- package/dist/cli.js +894 -0
- package/package.json +65 -8
- package/rules/app-compose/backend/auth.md +78 -0
- package/rules/app-compose/frontend/auth.md +55 -0
- package/rules/app-compose/frontend/component.md +55 -0
- package/rules/app-compose/frontend/page.md +86 -0
- package/rules/app-compose/frontend/screen-detailview.md +112 -0
- package/rules/app-compose/frontend/screen-form.md +145 -0
- package/rules/app-compose/frontend/screen-listview.md +159 -0
- package/rules/app-compose/structure.md +32 -0
- package/rules/module-development/commands.md +54 -0
- package/rules/module-development/cross-module-type-injection.md +28 -0
- package/rules/module-development/dependency-modules.md +24 -0
- package/rules/module-development/errors.md +12 -0
- package/rules/module-development/executors.md +67 -0
- package/rules/module-development/exports.md +13 -0
- package/rules/module-development/models.md +34 -0
- package/rules/module-development/structure.md +27 -0
- package/rules/module-development/sync-vs-async-operations.md +83 -0
- package/rules/module-development/testing.md +43 -0
- package/rules/sdk-best-practices/db-relations.md +74 -0
- package/rules/sdk-best-practices/sdk-docs.md +14 -0
- package/schemas/app-compose/actors.yml +34 -0
- package/schemas/app-compose/business-flow.yml +50 -0
- package/schemas/app-compose/requirements.yml +33 -0
- package/schemas/app-compose/resolver.yml +47 -0
- package/schemas/app-compose/screen.yml +81 -0
- package/schemas/app-compose/story.yml +67 -0
- package/schemas/module/command.yml +52 -0
- package/schemas/module/feature.yml +58 -0
- package/schemas/module/model.yml +70 -0
- package/schemas/module/module.yml +50 -0
- package/skills/1-module-docs/SKILL.md +107 -0
- package/skills/2-module-feature-breakdown/SKILL.md +66 -0
- package/skills/3-module-doc-review/SKILL.md +230 -0
- package/skills/4-module-tdd-implementation/SKILL.md +56 -0
- package/skills/5-module-implementation-review/SKILL.md +400 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
- package/skills/app-compose-3-doc-review/SKILL.md +112 -0
- package/skills/app-compose-4-design-mock/SKILL.md +248 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +122 -0
- package/skills/mock-scenario/SKILL.md +118 -0
- package/src/app.ts +1 -0
- package/src/cli.ts +120 -0
- package/src/commands/check.test.ts +30 -0
- package/src/commands/check.ts +66 -0
- package/src/commands/init.test.ts +77 -0
- package/src/commands/init.ts +87 -0
- package/src/commands/mock/index.ts +53 -0
- package/src/commands/mock/start.ts +179 -0
- package/src/commands/mock/validate.test.ts +185 -0
- package/src/commands/mock/validate.ts +198 -0
- package/src/commands/scaffold.test.ts +76 -0
- package/src/commands/scaffold.ts +119 -0
- package/src/commands/sync-check.test.ts +125 -0
- package/src/commands/sync-check.ts +182 -0
- package/src/integration.test.ts +63 -0
- package/src/mdschema.ts +48 -0
- package/src/mockServer.ts +55 -0
- package/src/module.ts +86 -0
- package/src/modules/accounting/.gitkeep +0 -0
- package/src/modules/coa-management/.gitkeep +0 -0
- package/src/modules/inventory/.gitkeep +0 -0
- package/src/modules/manufacturing/.gitkeep +0 -0
- package/src/modules/primitives/README.md +39 -0
- package/src/modules/primitives/command/activateCategory.test.ts +75 -0
- package/src/modules/primitives/command/activateCategory.ts +50 -0
- package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
- package/src/modules/primitives/command/activateCurrency.ts +50 -0
- package/src/modules/primitives/command/activateUnit.test.ts +53 -0
- package/src/modules/primitives/command/activateUnit.ts +50 -0
- package/src/modules/primitives/command/convertAmount.test.ts +275 -0
- package/src/modules/primitives/command/convertAmount.ts +126 -0
- package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
- package/src/modules/primitives/command/convertQuantity.ts +73 -0
- package/src/modules/primitives/command/createCategory.test.ts +126 -0
- package/src/modules/primitives/command/createCategory.ts +89 -0
- package/src/modules/primitives/command/createCurrency.test.ts +191 -0
- package/src/modules/primitives/command/createCurrency.ts +77 -0
- package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
- package/src/modules/primitives/command/createExchangeRate.ts +91 -0
- package/src/modules/primitives/command/createUnit.test.ts +214 -0
- package/src/modules/primitives/command/createUnit.ts +88 -0
- package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
- package/src/modules/primitives/command/deactivateCategory.ts +62 -0
- package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
- package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
- package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
- package/src/modules/primitives/command/deactivateUnit.ts +62 -0
- package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
- package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
- package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
- package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
- package/src/modules/primitives/db/currency.ts +30 -0
- package/src/modules/primitives/db/exchangeRate.ts +28 -0
- package/src/modules/primitives/db/unit.ts +32 -0
- package/src/modules/primitives/db/uomCategory.ts +32 -0
- package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
- package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
- package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
- package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
- package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
- package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
- package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
- package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
- package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
- package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
- package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
- package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
- package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
- package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
- package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
- package/src/modules/primitives/docs/features/uom-categories.md +52 -0
- package/src/modules/primitives/docs/models/Currency.md +45 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
- package/src/modules/primitives/docs/models/Unit.md +46 -0
- package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
- package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
- package/src/modules/primitives/index.ts +40 -0
- package/src/modules/primitives/lib/errors.ts +138 -0
- package/src/modules/primitives/lib/types.ts +20 -0
- package/src/modules/primitives/module.ts +66 -0
- package/src/modules/primitives/permissions.ts +18 -0
- package/src/modules/primitives/tailor.config.ts +11 -0
- package/src/modules/primitives/testing/fixtures.ts +161 -0
- package/src/modules/product-management/.gitkeep +0 -0
- package/src/modules/purchase/.gitkeep +0 -0
- package/src/modules/sales/.gitkeep +0 -0
- package/src/modules/shared/createContext.test.ts +39 -0
- package/src/modules/shared/createContext.ts +15 -0
- package/src/modules/shared/defineCommand.test.ts +42 -0
- package/src/modules/shared/defineCommand.ts +19 -0
- package/src/modules/shared/definePermissions.test.ts +146 -0
- package/src/modules/shared/definePermissions.ts +94 -0
- package/src/modules/shared/entityTypes.ts +15 -0
- package/src/modules/shared/errors.ts +22 -0
- package/src/modules/shared/index.ts +1 -0
- package/src/modules/shared/internal.ts +13 -0
- package/src/modules/shared/requirePermission.test.ts +47 -0
- package/src/modules/shared/requirePermission.ts +8 -0
- package/src/modules/shared/types.ts +4 -0
- package/src/modules/supplier-management/.gitkeep +0 -0
- package/src/modules/supplier-portal/.gitkeep +0 -0
- package/src/modules/testing/index.ts +120 -0
- package/src/modules/user-management/README.md +38 -0
- package/src/modules/user-management/command/activateUser.test.ts +112 -0
- package/src/modules/user-management/command/activateUser.ts +67 -0
- package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
- package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
- package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
- package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
- package/src/modules/user-management/command/createPermission.test.ts +143 -0
- package/src/modules/user-management/command/createPermission.ts +66 -0
- package/src/modules/user-management/command/createRole.test.ts +115 -0
- package/src/modules/user-management/command/createRole.ts +52 -0
- package/src/modules/user-management/command/createUser.test.ts +198 -0
- package/src/modules/user-management/command/createUser.ts +85 -0
- package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
- package/src/modules/user-management/command/deactivateUser.ts +67 -0
- package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
- package/src/modules/user-management/command/logAuditEvent.ts +59 -0
- package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
- package/src/modules/user-management/command/reactivateUser.ts +67 -0
- package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
- package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
- package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
- package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
- package/src/modules/user-management/db/auditEvent.ts +47 -0
- package/src/modules/user-management/db/permission.ts +31 -0
- package/src/modules/user-management/db/role.ts +28 -0
- package/src/modules/user-management/db/rolePermission.ts +44 -0
- package/src/modules/user-management/db/user.ts +38 -0
- package/src/modules/user-management/db/userRole.ts +44 -0
- package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
- package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
- package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
- package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
- package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
- package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
- package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
- package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
- package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
- package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
- package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
- package/src/modules/user-management/docs/features/audit-trail.md +80 -0
- package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
- package/src/modules/user-management/docs/features/user-account-management.md +64 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
- package/src/modules/user-management/docs/models/Permission.md +31 -0
- package/src/modules/user-management/docs/models/Role.md +31 -0
- package/src/modules/user-management/docs/models/RolePermission.md +33 -0
- package/src/modules/user-management/docs/models/User.md +47 -0
- package/src/modules/user-management/docs/models/UserRole.md +34 -0
- package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
- package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
- package/src/modules/user-management/generated/enums.ts +24 -0
- package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
- package/src/modules/user-management/index.ts +32 -0
- package/src/modules/user-management/lib/errors.ts +81 -0
- package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
- package/src/modules/user-management/lib/types.ts +31 -0
- package/src/modules/user-management/module.ts +77 -0
- package/src/modules/user-management/permissions.ts +15 -0
- package/src/modules/user-management/tailor.config.ts +11 -0
- package/src/modules/user-management/testing/fixtures.ts +98 -0
- package/src/schemas.ts +25 -0
- package/src/testing.ts +10 -0
- package/src/util.ts +3 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
|
+
import { createMockDb } from "../../testing/index";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import { RoleNotFoundError, UserNotActiveError, UserNotFoundError } from "../lib/errors";
|
|
6
|
+
import {
|
|
7
|
+
activeUser,
|
|
8
|
+
baseAuditEvent,
|
|
9
|
+
baseRole,
|
|
10
|
+
baseUserRole,
|
|
11
|
+
inactiveUser,
|
|
12
|
+
pendingUser,
|
|
13
|
+
} from "../testing/fixtures";
|
|
14
|
+
import { assignRoleToUser } from "./assignRoleToUser";
|
|
15
|
+
|
|
16
|
+
describe("assignRoleToUser", () => {
|
|
17
|
+
const ctx: CommandContext = {
|
|
18
|
+
actorId: "test-actor",
|
|
19
|
+
permissions: ["user-management:assignRoleToUser"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
it("throws when user does not exist", async () => {
|
|
23
|
+
const { db, spies } = createMockDb<DB>();
|
|
24
|
+
spies.select.mockReturnValue(undefined);
|
|
25
|
+
|
|
26
|
+
await expect(
|
|
27
|
+
assignRoleToUser(
|
|
28
|
+
db,
|
|
29
|
+
{
|
|
30
|
+
userId: "nonexistent-user",
|
|
31
|
+
roleId: baseRole.id,
|
|
32
|
+
actorId: "actor-1",
|
|
33
|
+
},
|
|
34
|
+
ctx,
|
|
35
|
+
),
|
|
36
|
+
).rejects.toBeInstanceOf(UserNotFoundError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("throws when role does not exist", async () => {
|
|
40
|
+
const { db, spies } = createMockDb<DB>();
|
|
41
|
+
spies.select.mockReturnValueOnce(activeUser).mockReturnValueOnce(undefined);
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
assignRoleToUser(
|
|
45
|
+
db,
|
|
46
|
+
{
|
|
47
|
+
userId: activeUser.id,
|
|
48
|
+
roleId: "nonexistent-role",
|
|
49
|
+
actorId: "actor-1",
|
|
50
|
+
},
|
|
51
|
+
ctx,
|
|
52
|
+
),
|
|
53
|
+
).rejects.toBeInstanceOf(RoleNotFoundError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("throws when user is PENDING", async () => {
|
|
57
|
+
const { db, spies } = createMockDb<DB>();
|
|
58
|
+
spies.select.mockReturnValueOnce(pendingUser).mockReturnValueOnce(baseRole);
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
assignRoleToUser(
|
|
62
|
+
db,
|
|
63
|
+
{
|
|
64
|
+
userId: pendingUser.id,
|
|
65
|
+
roleId: baseRole.id,
|
|
66
|
+
actorId: "actor-1",
|
|
67
|
+
},
|
|
68
|
+
ctx,
|
|
69
|
+
),
|
|
70
|
+
).rejects.toBeInstanceOf(UserNotActiveError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws when user is INACTIVE", async () => {
|
|
74
|
+
const { db, spies } = createMockDb<DB>();
|
|
75
|
+
spies.select.mockReturnValueOnce(inactiveUser).mockReturnValueOnce(baseRole);
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
assignRoleToUser(
|
|
79
|
+
db,
|
|
80
|
+
{
|
|
81
|
+
userId: inactiveUser.id,
|
|
82
|
+
roleId: baseRole.id,
|
|
83
|
+
actorId: "actor-1",
|
|
84
|
+
},
|
|
85
|
+
ctx,
|
|
86
|
+
),
|
|
87
|
+
).rejects.toBeInstanceOf(UserNotActiveError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns success when assignment already exists (idempotent)", async () => {
|
|
91
|
+
const { db, spies } = createMockDb<DB>();
|
|
92
|
+
const updatedUser = { ...activeUser, permissions: ["orders:read"] };
|
|
93
|
+
spies.select
|
|
94
|
+
.mockReturnValueOnce(activeUser)
|
|
95
|
+
.mockReturnValueOnce(baseRole)
|
|
96
|
+
.mockReturnValueOnce(baseUserRole) // Assignment exists
|
|
97
|
+
.mockReturnValueOnce([{ key: "orders:read" }]); // Permission keys from recompute
|
|
98
|
+
spies.update.mockReturnValueOnce(updatedUser);
|
|
99
|
+
|
|
100
|
+
const result = await assignRoleToUser(
|
|
101
|
+
db,
|
|
102
|
+
{
|
|
103
|
+
userId: activeUser.id,
|
|
104
|
+
roleId: baseRole.id,
|
|
105
|
+
actorId: "actor-1",
|
|
106
|
+
},
|
|
107
|
+
ctx,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(result.userRole).toEqual(baseUserRole);
|
|
111
|
+
expect(result.user.permissions).toEqual(["orders:read"]);
|
|
112
|
+
expect(spies.insert).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("creates UserRole and logs ROLE_ASSIGNED event", async () => {
|
|
116
|
+
const { db, spies } = createMockDb<DB>();
|
|
117
|
+
const newUserRole = {
|
|
118
|
+
...baseUserRole,
|
|
119
|
+
id: "new-user-role-id",
|
|
120
|
+
};
|
|
121
|
+
const createdAuditEvent = {
|
|
122
|
+
...baseAuditEvent,
|
|
123
|
+
eventType: "ROLE_ASSIGNED" as const,
|
|
124
|
+
actorId: "actor-1",
|
|
125
|
+
targetId: activeUser.id,
|
|
126
|
+
targetType: "User",
|
|
127
|
+
};
|
|
128
|
+
const updatedUser = { ...activeUser, permissions: ["orders:read"] };
|
|
129
|
+
|
|
130
|
+
spies.select
|
|
131
|
+
.mockReturnValueOnce(activeUser)
|
|
132
|
+
.mockReturnValueOnce(baseRole)
|
|
133
|
+
.mockReturnValueOnce(undefined) // No existing assignment
|
|
134
|
+
.mockReturnValueOnce([{ key: "orders:read" }]); // Permission keys from recompute
|
|
135
|
+
spies.insert.mockReturnValueOnce(newUserRole).mockReturnValueOnce(createdAuditEvent);
|
|
136
|
+
spies.update.mockReturnValueOnce(updatedUser);
|
|
137
|
+
|
|
138
|
+
const result = await assignRoleToUser(
|
|
139
|
+
db,
|
|
140
|
+
{
|
|
141
|
+
userId: activeUser.id,
|
|
142
|
+
roleId: baseRole.id,
|
|
143
|
+
actorId: "actor-1",
|
|
144
|
+
},
|
|
145
|
+
ctx,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(result.userRole.userId).toBe(activeUser.id);
|
|
149
|
+
expect(result.userRole.roleId).toBe(baseRole.id);
|
|
150
|
+
expect(result.auditEvent?.eventType).toBe("ROLE_ASSIGNED");
|
|
151
|
+
expect(result.user.permissions).toEqual(["orders:read"]);
|
|
152
|
+
expect(spies.insert).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws when permission is missing", async () => {
|
|
156
|
+
const { db } = createMockDb<DB>();
|
|
157
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
158
|
+
await expect(
|
|
159
|
+
assignRoleToUser(db, { userId: "user-1", roleId: "role-1", actorId: "actor-1" }, denied),
|
|
160
|
+
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import { RoleNotFoundError, UserNotActiveError, UserNotFoundError } from "../lib/errors";
|
|
4
|
+
import { recomputeUserPermissions } from "../lib/recomputeUserPermissions";
|
|
5
|
+
import { permissions } from "../permissions";
|
|
6
|
+
|
|
7
|
+
export interface AssignRoleToUserInput {
|
|
8
|
+
userId: string;
|
|
9
|
+
roleId: string;
|
|
10
|
+
actorId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Function: assignRoleToUser
|
|
15
|
+
*
|
|
16
|
+
* Assigns a role to a user. Only ACTIVE users can receive role assignments.
|
|
17
|
+
* Operation is idempotent - assigning the same role twice does not create
|
|
18
|
+
* duplicate records or error.
|
|
19
|
+
*/
|
|
20
|
+
export const assignRoleToUser = defineCommand(
|
|
21
|
+
permissions.assignRoleToUser,
|
|
22
|
+
async (db: DB, input: AssignRoleToUserInput) => {
|
|
23
|
+
const user = await db
|
|
24
|
+
.selectFrom("User")
|
|
25
|
+
.selectAll()
|
|
26
|
+
.where("id", "=", input.userId)
|
|
27
|
+
.executeTakeFirst();
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
throw new UserNotFoundError(input.userId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const role = await db
|
|
34
|
+
.selectFrom("Role")
|
|
35
|
+
.selectAll()
|
|
36
|
+
.where("id", "=", input.roleId)
|
|
37
|
+
.executeTakeFirst();
|
|
38
|
+
|
|
39
|
+
if (!role) {
|
|
40
|
+
throw new RoleNotFoundError(input.roleId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (user.status !== "ACTIVE") {
|
|
44
|
+
throw new UserNotActiveError(input.userId, user.status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Idempotent: return existing assignment, recompute permissions for consistency
|
|
48
|
+
const existingAssignment = await db
|
|
49
|
+
.selectFrom("UserRole")
|
|
50
|
+
.selectAll()
|
|
51
|
+
.where("userId", "=", input.userId)
|
|
52
|
+
.where("roleId", "=", input.roleId)
|
|
53
|
+
.executeTakeFirst();
|
|
54
|
+
|
|
55
|
+
if (existingAssignment) {
|
|
56
|
+
const updatedUser = await recomputeUserPermissions(db, input.userId);
|
|
57
|
+
return { userRole: existingAssignment, user: updatedUser };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const userRole = await db
|
|
61
|
+
.insertInto("UserRole")
|
|
62
|
+
.values({
|
|
63
|
+
userId: input.userId,
|
|
64
|
+
roleId: input.roleId,
|
|
65
|
+
createdAt: new Date(),
|
|
66
|
+
updatedAt: null,
|
|
67
|
+
})
|
|
68
|
+
.returningAll()
|
|
69
|
+
.executeTakeFirst();
|
|
70
|
+
|
|
71
|
+
const auditEvent = await db
|
|
72
|
+
.insertInto("AuditEvent")
|
|
73
|
+
.values({
|
|
74
|
+
eventType: "ROLE_ASSIGNED",
|
|
75
|
+
actorId: input.actorId,
|
|
76
|
+
targetId: input.userId,
|
|
77
|
+
targetType: "User",
|
|
78
|
+
payload: JSON.stringify({
|
|
79
|
+
userId: input.userId,
|
|
80
|
+
roleId: input.roleId,
|
|
81
|
+
roleName: role.name,
|
|
82
|
+
}),
|
|
83
|
+
createdAt: new Date(),
|
|
84
|
+
updatedAt: null,
|
|
85
|
+
})
|
|
86
|
+
.returningAll()
|
|
87
|
+
.executeTakeFirst();
|
|
88
|
+
|
|
89
|
+
const updatedUser = await recomputeUserPermissions(db, input.userId);
|
|
90
|
+
|
|
91
|
+
return { userRole: userRole!, user: updatedUser, auditEvent: auditEvent! };
|
|
92
|
+
},
|
|
93
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
|
+
import { createMockDb } from "../../testing/index";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import {
|
|
6
|
+
DuplicatePermissionKeyError,
|
|
7
|
+
InvalidPermissionKeyFormatError,
|
|
8
|
+
MissingRequiredFieldError,
|
|
9
|
+
} from "../lib/errors";
|
|
10
|
+
import { basePermission } from "../testing/fixtures";
|
|
11
|
+
import { makeCreatePermission } from "./createPermission";
|
|
12
|
+
|
|
13
|
+
const createPermission = makeCreatePermission();
|
|
14
|
+
|
|
15
|
+
describe("createPermission", () => {
|
|
16
|
+
const ctx: CommandContext = {
|
|
17
|
+
actorId: "test-actor",
|
|
18
|
+
permissions: ["user-management:createPermission"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Error cases
|
|
22
|
+
it("throws when key is empty", async () => {
|
|
23
|
+
const { db } = createMockDb<DB>();
|
|
24
|
+
|
|
25
|
+
await expect(createPermission(db, { key: "" }, ctx)).rejects.toBeInstanceOf(
|
|
26
|
+
MissingRequiredFieldError,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("throws when key format is invalid (no colon)", async () => {
|
|
31
|
+
const { db } = createMockDb<DB>();
|
|
32
|
+
|
|
33
|
+
await expect(createPermission(db, { key: "invalidkey" }, ctx)).rejects.toBeInstanceOf(
|
|
34
|
+
InvalidPermissionKeyFormatError,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws when key format is invalid (multiple colons)", async () => {
|
|
39
|
+
const { db } = createMockDb<DB>();
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
createPermission(db, { key: "resource:action:extra" }, ctx),
|
|
43
|
+
).rejects.toBeInstanceOf(InvalidPermissionKeyFormatError);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws when key format is invalid (empty resource)", async () => {
|
|
47
|
+
const { db } = createMockDb<DB>();
|
|
48
|
+
|
|
49
|
+
await expect(createPermission(db, { key: ":action" }, ctx)).rejects.toBeInstanceOf(
|
|
50
|
+
InvalidPermissionKeyFormatError,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws when key format is invalid (empty action)", async () => {
|
|
55
|
+
const { db } = createMockDb<DB>();
|
|
56
|
+
|
|
57
|
+
await expect(createPermission(db, { key: "resource:" }, ctx)).rejects.toBeInstanceOf(
|
|
58
|
+
InvalidPermissionKeyFormatError,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws when permission key already exists", async () => {
|
|
63
|
+
const { db, spies } = createMockDb<DB>();
|
|
64
|
+
spies.select.mockReturnValue(basePermission);
|
|
65
|
+
|
|
66
|
+
await expect(createPermission(db, { key: basePermission.key }, ctx)).rejects.toBeInstanceOf(
|
|
67
|
+
DuplicatePermissionKeyError,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Success cases
|
|
72
|
+
it("creates permission with valid key", async () => {
|
|
73
|
+
const { db, spies } = createMockDb<DB>();
|
|
74
|
+
const createdPermission = {
|
|
75
|
+
...basePermission,
|
|
76
|
+
id: "new-permission-id",
|
|
77
|
+
key: "users:read",
|
|
78
|
+
description: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
spies.select.mockReturnValue(undefined); // No existing permission
|
|
82
|
+
spies.insert.mockReturnValue(createdPermission);
|
|
83
|
+
|
|
84
|
+
const result = await createPermission(db, { key: "users:read" }, ctx);
|
|
85
|
+
|
|
86
|
+
expect(result.permission.key).toBe("users:read");
|
|
87
|
+
expect(spies.insert).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("creates permission with description", async () => {
|
|
91
|
+
const { db, spies } = createMockDb<DB>();
|
|
92
|
+
const createdPermission = {
|
|
93
|
+
...basePermission,
|
|
94
|
+
id: "new-permission-id",
|
|
95
|
+
key: "users:write",
|
|
96
|
+
description: "Permission to write users",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
spies.select.mockReturnValue(undefined);
|
|
100
|
+
spies.insert.mockReturnValue(createdPermission);
|
|
101
|
+
|
|
102
|
+
const result = await createPermission(
|
|
103
|
+
db,
|
|
104
|
+
{
|
|
105
|
+
key: "users:write",
|
|
106
|
+
description: "Permission to write users",
|
|
107
|
+
},
|
|
108
|
+
ctx,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(result.permission.key).toBe("users:write");
|
|
112
|
+
expect(result.permission.description).toBe("Permission to write users");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws when permission is missing", async () => {
|
|
116
|
+
const { db } = createMockDb<DB>();
|
|
117
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
118
|
+
await expect(createPermission(db, { key: "users:read" }, denied)).rejects.toBeInstanceOf(
|
|
119
|
+
InsufficientPermissionError,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("passes custom fields through to insert", async () => {
|
|
124
|
+
const createPermissionWithFields = makeCreatePermission<{ module: string }>();
|
|
125
|
+
const { db, spies } = createMockDb<DB>();
|
|
126
|
+
const createdPermission = {
|
|
127
|
+
...basePermission,
|
|
128
|
+
id: "new-permission-id",
|
|
129
|
+
key: "users:read",
|
|
130
|
+
module: "user-management",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
spies.select.mockReturnValue(undefined);
|
|
134
|
+
spies.insert.mockReturnValue(createdPermission);
|
|
135
|
+
|
|
136
|
+
await createPermissionWithFields(db, { key: "users:read", module: "user-management" }, ctx);
|
|
137
|
+
|
|
138
|
+
expect(spies.values).toHaveBeenNthCalledWith(
|
|
139
|
+
1,
|
|
140
|
+
expect.objectContaining({ module: "user-management" }),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import {
|
|
4
|
+
DuplicatePermissionKeyError,
|
|
5
|
+
InvalidPermissionKeyFormatError,
|
|
6
|
+
MissingRequiredFieldError,
|
|
7
|
+
} from "../lib/errors";
|
|
8
|
+
import { permissions } from "../permissions";
|
|
9
|
+
|
|
10
|
+
interface CreatePermissionInput {
|
|
11
|
+
key: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PERMISSION_KEY_PATTERN = /^[a-zA-Z0-9_-]+:[a-zA-Z0-9_-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Function: createPermission
|
|
19
|
+
*
|
|
20
|
+
* Creates a new permission with the specified key in resource:action format.
|
|
21
|
+
* Validates that the key follows the correct format and is unique.
|
|
22
|
+
*/
|
|
23
|
+
export function makeCreatePermission<CF extends Record<string, unknown>>() {
|
|
24
|
+
return defineCommand(
|
|
25
|
+
permissions.createPermission,
|
|
26
|
+
async (db: DB, input: CreatePermissionInput & CF) => {
|
|
27
|
+
const { key, description, ...customFields } = input;
|
|
28
|
+
|
|
29
|
+
// 1. Validate key is provided
|
|
30
|
+
if (!key || key.trim() === "") {
|
|
31
|
+
throw new MissingRequiredFieldError("key");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Validate key format (resource:action)
|
|
35
|
+
if (!PERMISSION_KEY_PATTERN.test(key)) {
|
|
36
|
+
throw new InvalidPermissionKeyFormatError(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Check if permission key already exists
|
|
40
|
+
const existingPermission = await db
|
|
41
|
+
.selectFrom("Permission")
|
|
42
|
+
.selectAll()
|
|
43
|
+
.where("key", "=", key)
|
|
44
|
+
.executeTakeFirst();
|
|
45
|
+
|
|
46
|
+
if (existingPermission) {
|
|
47
|
+
throw new DuplicatePermissionKeyError(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 4. Create permission
|
|
51
|
+
const permission = await db
|
|
52
|
+
.insertInto("Permission")
|
|
53
|
+
.values({
|
|
54
|
+
...(customFields as Record<string, unknown>),
|
|
55
|
+
key,
|
|
56
|
+
description: description ?? null,
|
|
57
|
+
createdAt: new Date(),
|
|
58
|
+
updatedAt: null,
|
|
59
|
+
})
|
|
60
|
+
.returningAll()
|
|
61
|
+
.executeTakeFirst();
|
|
62
|
+
|
|
63
|
+
return { permission: permission! };
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
|
+
import { createMockDb } from "../../testing/index";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import { DuplicateRoleNameError, MissingRequiredFieldError } from "../lib/errors";
|
|
6
|
+
import { baseRole } from "../testing/fixtures";
|
|
7
|
+
import { makeCreateRole } from "./createRole";
|
|
8
|
+
|
|
9
|
+
const createRole = makeCreateRole();
|
|
10
|
+
|
|
11
|
+
describe("createRole", () => {
|
|
12
|
+
const ctx: CommandContext = {
|
|
13
|
+
actorId: "test-actor",
|
|
14
|
+
permissions: ["user-management:createRole"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Error cases
|
|
18
|
+
it("throws when name is empty", async () => {
|
|
19
|
+
const { db } = createMockDb<DB>();
|
|
20
|
+
|
|
21
|
+
await expect(createRole(db, { name: "" }, ctx)).rejects.toBeInstanceOf(
|
|
22
|
+
MissingRequiredFieldError,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("throws when name is whitespace only", async () => {
|
|
27
|
+
const { db } = createMockDb<DB>();
|
|
28
|
+
|
|
29
|
+
await expect(createRole(db, { name: " " }, ctx)).rejects.toBeInstanceOf(
|
|
30
|
+
MissingRequiredFieldError,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("throws when role name already exists", async () => {
|
|
35
|
+
const { db, spies } = createMockDb<DB>();
|
|
36
|
+
spies.select.mockReturnValue(baseRole);
|
|
37
|
+
|
|
38
|
+
await expect(createRole(db, { name: baseRole.name }, ctx)).rejects.toBeInstanceOf(
|
|
39
|
+
DuplicateRoleNameError,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Success cases
|
|
44
|
+
it("creates role with valid name", async () => {
|
|
45
|
+
const { db, spies } = createMockDb<DB>();
|
|
46
|
+
const createdRole = {
|
|
47
|
+
...baseRole,
|
|
48
|
+
id: "new-role-id",
|
|
49
|
+
name: "Viewer",
|
|
50
|
+
description: null,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
spies.select.mockReturnValue(undefined); // No existing role
|
|
54
|
+
spies.insert.mockReturnValue(createdRole);
|
|
55
|
+
|
|
56
|
+
const result = await createRole(db, { name: "Viewer" }, ctx);
|
|
57
|
+
|
|
58
|
+
expect(result.role.name).toBe("Viewer");
|
|
59
|
+
expect(spies.insert).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("creates role with description", async () => {
|
|
63
|
+
const { db, spies } = createMockDb<DB>();
|
|
64
|
+
const createdRole = {
|
|
65
|
+
...baseRole,
|
|
66
|
+
id: "new-role-id",
|
|
67
|
+
name: "Editor",
|
|
68
|
+
description: "Can edit content",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
spies.select.mockReturnValue(undefined);
|
|
72
|
+
spies.insert.mockReturnValue(createdRole);
|
|
73
|
+
|
|
74
|
+
const result = await createRole(
|
|
75
|
+
db,
|
|
76
|
+
{
|
|
77
|
+
name: "Editor",
|
|
78
|
+
description: "Can edit content",
|
|
79
|
+
},
|
|
80
|
+
ctx,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(result.role.name).toBe("Editor");
|
|
84
|
+
expect(result.role.description).toBe("Can edit content");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("throws when permission is missing", async () => {
|
|
88
|
+
const { db } = createMockDb<DB>();
|
|
89
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
90
|
+
await expect(createRole(db, { name: "Test" }, denied)).rejects.toBeInstanceOf(
|
|
91
|
+
InsufficientPermissionError,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("passes custom fields through to insert", async () => {
|
|
96
|
+
const createRoleWithFields = makeCreateRole<{ department: string }>();
|
|
97
|
+
const { db, spies } = createMockDb<DB>();
|
|
98
|
+
const createdRole = {
|
|
99
|
+
...baseRole,
|
|
100
|
+
id: "new-role-id",
|
|
101
|
+
name: "Viewer",
|
|
102
|
+
department: "Engineering",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
spies.select.mockReturnValue(undefined);
|
|
106
|
+
spies.insert.mockReturnValue(createdRole);
|
|
107
|
+
|
|
108
|
+
await createRoleWithFields(db, { name: "Viewer", department: "Engineering" }, ctx);
|
|
109
|
+
|
|
110
|
+
expect(spies.values).toHaveBeenNthCalledWith(
|
|
111
|
+
1,
|
|
112
|
+
expect.objectContaining({ department: "Engineering" }),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import { DuplicateRoleNameError, MissingRequiredFieldError } from "../lib/errors";
|
|
4
|
+
import { permissions } from "../permissions";
|
|
5
|
+
|
|
6
|
+
interface CreateRoleInput {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Function: createRole
|
|
13
|
+
*
|
|
14
|
+
* Creates a new role with the specified name.
|
|
15
|
+
* Validates that the name is unique within the system.
|
|
16
|
+
*/
|
|
17
|
+
export function makeCreateRole<CF extends Record<string, unknown>>() {
|
|
18
|
+
return defineCommand(permissions.createRole, async (db: DB, input: CreateRoleInput & CF) => {
|
|
19
|
+
const { name, description, ...customFields } = input;
|
|
20
|
+
|
|
21
|
+
// 1. Validate name is provided
|
|
22
|
+
if (!name || name.trim() === "") {
|
|
23
|
+
throw new MissingRequiredFieldError("name");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 2. Check if role name already exists
|
|
27
|
+
const existingRole = await db
|
|
28
|
+
.selectFrom("Role")
|
|
29
|
+
.selectAll()
|
|
30
|
+
.where("name", "=", name)
|
|
31
|
+
.executeTakeFirst();
|
|
32
|
+
|
|
33
|
+
if (existingRole) {
|
|
34
|
+
throw new DuplicateRoleNameError(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. Create role
|
|
38
|
+
const role = await db
|
|
39
|
+
.insertInto("Role")
|
|
40
|
+
.values({
|
|
41
|
+
...(customFields as Record<string, unknown>),
|
|
42
|
+
name,
|
|
43
|
+
description: description ?? null,
|
|
44
|
+
createdAt: new Date(),
|
|
45
|
+
updatedAt: null,
|
|
46
|
+
})
|
|
47
|
+
.returningAll()
|
|
48
|
+
.executeTakeFirst();
|
|
49
|
+
|
|
50
|
+
return { role: role! };
|
|
51
|
+
});
|
|
52
|
+
}
|