@jini-ai/user-management 0.6.15-rc.2 → 0.6.16
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 +2 -0
- package/dist/admin/adapters/http.js +15 -5
- package/dist/admin/adapters/memory.js +103 -38
- package/dist/admin/conformance/index.js +48 -30
- package/dist/admin/controllers/controller-store.js +18 -6
- package/dist/admin/controllers/members.controller.js +48 -21
- package/dist/admin/controllers/roles.controller.js +98 -49
- package/dist/admin/controllers/users.controller.js +22 -11
- package/dist/admin/error-copy.js +3 -1
- package/dist/admin/ports.d.ts +11 -10
- package/dist/admin/react/components/PermissionPanel.js +3 -2
- package/dist/admin/react/components/PolicyRow.js +3 -2
- package/dist/admin/react/components/RoleRow.js +3 -2
- package/dist/admin/react/components/UsersConfirmation.js +1 -2
- package/dist/admin/react/hooks/AuthScope.hooks.js +2 -1
- package/dist/admin/react/hooks/MembersPage.hooks.js +23 -10
- package/dist/admin/react/hooks/MembersScope.hooks.js +2 -1
- package/dist/admin/react/hooks/UsersPage.hooks.js +13 -5
- package/dist/admin/react/hooks/UsersScope.hooks.js +2 -1
- package/dist/admin/react/index.d.ts +6 -6
- package/dist/admin/rules.js +15 -7
- package/dist/core/ports.d.ts +6 -0
- package/dist/react/errors.js +3 -1
- package/dist/react/users/Users.js +2 -1
- package/dist/react/users/rules.js +9 -5
- package/dist/server/admin-crud-service.js +17 -13
- package/dist/server/agent-tool-registrations.js +18 -15
- package/dist/server/agent-tools.js +38 -31
- package/dist/server/auth-challenge.js +4 -3
- package/dist/server/hasher.js +2 -1
- package/dist/server/repo.memory-transactions.js +13 -9
- package/dist/server/repo.memory.d.ts +3 -0
- package/dist/server/repo.memory.js +8 -0
- package/dist/server/seed.d.ts +17 -1
- package/dist/server/seed.js +25 -2
- package/package.json +41 -32
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const rolesKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "
|
|
1
|
+
export declare const rolesKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Menu" | "Badge" | "Button" | "TextField" | "ConfirmDialog">;
|
|
2
2
|
/** Optional composition surface. Identity services remain owned by user-management. */
|
|
3
3
|
export declare function roles(_required: Record<string, never>, _optional?: Record<string, never>): {
|
|
4
4
|
id: "roles";
|
|
@@ -56,11 +56,11 @@ export declare function roles(_required: Record<string, never>, _optional?: Reco
|
|
|
56
56
|
children: import("react").ReactNode;
|
|
57
57
|
}, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/RolesScope.hooks.js").RolesScope | null>>;
|
|
58
58
|
};
|
|
59
|
-
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "
|
|
59
|
+
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Menu" | "Badge" | "Button" | "TextField" | "ConfirmDialog">;
|
|
60
60
|
};
|
|
61
61
|
export type { RolesPageProps, RolesTabProps } from './hooks/RolesBinding.hooks.js';
|
|
62
|
-
export declare const usersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "
|
|
63
|
-
export declare const membersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "
|
|
62
|
+
export declare const usersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
|
|
63
|
+
export declare const membersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "ConfirmDialog">;
|
|
64
64
|
export declare const authKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Button" | "TextField">;
|
|
65
65
|
export declare function users(_required: Record<string, never>, _optional?: Record<string, never>): {
|
|
66
66
|
id: "users";
|
|
@@ -109,7 +109,7 @@ export declare function users(_required: Record<string, never>, _optional?: Reco
|
|
|
109
109
|
children: import("react").ReactNode;
|
|
110
110
|
}, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/UsersScope.hooks.js").UsersScope | null>>;
|
|
111
111
|
};
|
|
112
|
-
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "
|
|
112
|
+
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
|
|
113
113
|
};
|
|
114
114
|
export declare function members(_required: Record<string, never>, _optional?: Record<string, never>): {
|
|
115
115
|
id: "members";
|
|
@@ -154,7 +154,7 @@ export declare function members(_required: Record<string, never>, _optional?: Re
|
|
|
154
154
|
children: import("react").ReactNode;
|
|
155
155
|
}, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/MembersScope.hooks.js").MembersScope | null>>;
|
|
156
156
|
};
|
|
157
|
-
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "
|
|
157
|
+
kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "ConfirmDialog">;
|
|
158
158
|
};
|
|
159
159
|
export declare function auth(_required: Record<string, never>, _optional?: Record<string, never>): {
|
|
160
160
|
id: "auth";
|
package/dist/admin/rules.js
CHANGED
|
@@ -22,13 +22,21 @@ export function describeRolesError({ error, fallback }, _optional = {}) {
|
|
|
22
22
|
return errorMessageOr({ error, fallback });
|
|
23
23
|
}
|
|
24
24
|
export function destructiveCopy({ target }, _optional = {}) {
|
|
25
|
-
if (target?.kind === 'permission')
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
if (target?.kind === 'permission') {
|
|
26
|
+
const scope = target.row.resourceType ? ` (${target.row.resourceType})` : '';
|
|
27
|
+
return {
|
|
28
|
+
title: 'Remove permission?', label: 'Remove', tone: 'warning',
|
|
29
|
+
body: `Remove "${target.row.permission}"${scope} from this policy? This changes live access immediately.`, consequence: 'changes live access immediately'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
28
32
|
if (target?.kind === 'policy')
|
|
29
|
-
return {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
return {
|
|
34
|
+
title: 'Delete policy?', label: 'Delete', tone: 'danger',
|
|
35
|
+
body: `Delete "${target.policy.name}"? Policies still attached to users or roles cannot be deleted.`, consequence: 'cannot be undone'
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
title: 'Delete role?', label: 'Delete', tone: 'danger',
|
|
39
|
+
body: target?.kind === 'role' ? `Delete "${target.role.name}"? Roles still assigned to users cannot be deleted.` : '', consequence: 'cannot be undone'
|
|
40
|
+
};
|
|
33
41
|
}
|
|
34
42
|
//# sourceMappingURL=rules.js.map
|
package/dist/core/ports.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface PrincipalRepoPort {
|
|
|
27
27
|
workspaceId: UUID;
|
|
28
28
|
}): Promise<PrincipalRecord[]>;
|
|
29
29
|
save(record: PrincipalRecord): Promise<void>;
|
|
30
|
+
/** Atomically insert by (workspaceId, id), or return the unchanged existing row.
|
|
31
|
+
* Durable adapters must enforce uniqueness in storage across connections/processes.
|
|
32
|
+
* Optional for existing hosts; ensurePrincipal fails closed without this capability. */
|
|
33
|
+
insertIfAbsent?(required: {
|
|
34
|
+
record: PrincipalRecord;
|
|
35
|
+
}, optional?: Record<string, never>): Promise<PrincipalRecord>;
|
|
30
36
|
}
|
|
31
37
|
export interface UserRepoPort {
|
|
32
38
|
findByPrincipalId(required: {
|
package/dist/react/errors.js
CHANGED
|
@@ -5,7 +5,9 @@ function errorMessage(error) {
|
|
|
5
5
|
}
|
|
6
6
|
/** The string `code` an Error or plain object carries, or `undefined` when there is none. */
|
|
7
7
|
function errorCode(error) {
|
|
8
|
-
|
|
8
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
9
|
+
return undefined;
|
|
10
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
9
11
|
}
|
|
10
12
|
/** Error codes are data, so any host transport can supply them without a shared error class. */
|
|
11
13
|
export function describeIdentityError({ error, fallback, translate }, { messages = {} } = {}) {
|
|
@@ -107,6 +107,7 @@ function resolveResetPasswordFieldsHook(override) {
|
|
|
107
107
|
function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordError, setPasswordError, passwordSaving, confirmResetPassword, t, useFields: useFieldsProp, }) {
|
|
108
108
|
const useFields = resolveResetPasswordFieldsHook(useFieldsProp);
|
|
109
109
|
const fields = useFields({ resetPasswordFor, newPassword });
|
|
110
|
+
const displayedError = fields.mismatch ? t("Passwords do not match.") : passwordError;
|
|
110
111
|
/** Blocks the reset entirely when the two fields disagree — a mismatch never reaches
|
|
111
112
|
* `confirmResetPassword`/the server. The inline message below is driven by the same `mismatch`
|
|
112
113
|
* value, so it is already visible by the time a blocked click can happen; there is nothing
|
|
@@ -122,7 +123,7 @@ function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPas
|
|
|
122
123
|
// `-confirm` sub-handle). Two elements answering to the same handle is a
|
|
123
124
|
// page-authoring bug the page driver refuses to resolve (see
|
|
124
125
|
// `dom-page-driver.ts`'s `ambiguousHandleError`), so this field gets its own name.
|
|
125
|
-
agentBase: "users-reset-password-retype", t: t }), fields.mismatch
|
|
126
|
+
agentBase: "users-reset-password-retype", t: t }), fields.mismatch || passwordError ? (_jsx("p", { className: "save-error", role: "alert", children: displayedError })) : null] })) : null, confirmLabel: t("Reset password"), tone: "warning", pending: passwordSaving, onConfirm: handleConfirm, onCancel: () => {
|
|
126
127
|
setResetPasswordFor(null);
|
|
127
128
|
setNewPassword("");
|
|
128
129
|
setPasswordError(null);
|
|
@@ -60,8 +60,10 @@ export function userAccountCapabilities(required, _optional = {}) {
|
|
|
60
60
|
}));
|
|
61
61
|
const mayAct = known && (callerIsOwner || !protectedAccount);
|
|
62
62
|
const destructive = mayAct && callerPrincipalId !== user.principalId;
|
|
63
|
-
return {
|
|
64
|
-
|
|
63
|
+
return {
|
|
64
|
+
canDelete: destructive && canManageUserTrash, canDisable: destructive,
|
|
65
|
+
canEnable: mayAct, canResetPassword: mayAct
|
|
66
|
+
};
|
|
65
67
|
}
|
|
66
68
|
export function isOwnerAccount(required, _optional = {}) {
|
|
67
69
|
return required.user.isOwner ?? required.user.roleIds.some(id => {
|
|
@@ -138,9 +140,11 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
|
|
|
138
140
|
onSelect: () => handlers.onRequestDelete(user),
|
|
139
141
|
});
|
|
140
142
|
}
|
|
141
|
-
return items.filter(item =>
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
return items.filter(item => {
|
|
144
|
+
if (item.key === "toggle")
|
|
145
|
+
return user.status === "active" ? permissions.canDisable : permissions.canEnable;
|
|
146
|
+
return item.key !== "reset-password" || permissions.canResetPassword;
|
|
147
|
+
});
|
|
144
148
|
}
|
|
145
149
|
/**
|
|
146
150
|
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
@@ -6,6 +6,8 @@ import { normalizeUsername } from "./username.js";
|
|
|
6
6
|
import { isKnownPermission } from "../core/permissions.js";
|
|
7
7
|
import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
|
|
8
8
|
import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
|
|
9
|
+
const USER_MANAGE = "user.manage";
|
|
10
|
+
const ROLE_MANAGE = "role.manage";
|
|
9
11
|
/**
|
|
10
12
|
* @file An amendment that completes the users/roles/policies admin CRUD surface
|
|
11
13
|
* (feature.spec.md..19).
|
|
@@ -56,7 +58,7 @@ export async function disablePrincipal(required) {
|
|
|
56
58
|
deps,
|
|
57
59
|
workspaceId: input.workspaceId,
|
|
58
60
|
callerPrincipalId: input.callerPrincipalId,
|
|
59
|
-
permissions: [
|
|
61
|
+
permissions: [USER_MANAGE],
|
|
60
62
|
});
|
|
61
63
|
const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
|
|
62
64
|
if (!target)
|
|
@@ -114,7 +116,7 @@ export async function enablePrincipal(required) {
|
|
|
114
116
|
deps,
|
|
115
117
|
workspaceId: input.workspaceId,
|
|
116
118
|
callerPrincipalId: input.callerPrincipalId,
|
|
117
|
-
permissions: [
|
|
119
|
+
permissions: [USER_MANAGE],
|
|
118
120
|
});
|
|
119
121
|
const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
|
|
120
122
|
if (!target)
|
|
@@ -161,8 +163,8 @@ export async function updateUser(required, optional = {}) {
|
|
|
161
163
|
callerPrincipalId: input.callerPrincipalId,
|
|
162
164
|
// Preserve existing self-profile access without letting member.manage edit other operators.
|
|
163
165
|
permissions: input.username === undefined && input.callerPrincipalId === input.principalId
|
|
164
|
-
? [
|
|
165
|
-
: [
|
|
166
|
+
? [USER_MANAGE, "member.manage"]
|
|
167
|
+
: [USER_MANAGE],
|
|
166
168
|
});
|
|
167
169
|
const target = await deps.repos.users.findByPrincipalId({
|
|
168
170
|
workspaceId: input.workspaceId,
|
|
@@ -184,9 +186,11 @@ export async function updateUser(required, optional = {}) {
|
|
|
184
186
|
await deps.repos.users.save(updated);
|
|
185
187
|
if (username !== target.username) {
|
|
186
188
|
// Renaming never changes the principal id or credentials: existing sessions stay valid.
|
|
187
|
-
await deps.usernameChanges.record({
|
|
189
|
+
await deps.usernameChanges.record({
|
|
190
|
+
workspaceId: input.workspaceId, principalId: input.principalId,
|
|
188
191
|
callerPrincipalId: input.callerPrincipalId, previousUsername: target.username, username,
|
|
189
|
-
occurredAt: kernelNowIso({ clock: deps.clock })
|
|
192
|
+
occurredAt: kernelNowIso({ clock: deps.clock })
|
|
193
|
+
});
|
|
190
194
|
}
|
|
191
195
|
return { user: updated };
|
|
192
196
|
},
|
|
@@ -283,7 +287,7 @@ export async function resetUserPassword(required) {
|
|
|
283
287
|
deps,
|
|
284
288
|
workspaceId: input.workspaceId,
|
|
285
289
|
callerPrincipalId: input.callerPrincipalId,
|
|
286
|
-
permissions: [
|
|
290
|
+
permissions: [USER_MANAGE],
|
|
287
291
|
});
|
|
288
292
|
const target = await deps.repos.users.findByPrincipalId({
|
|
289
293
|
workspaceId: input.workspaceId,
|
|
@@ -344,7 +348,7 @@ export async function updateRole(required) {
|
|
|
344
348
|
deps,
|
|
345
349
|
workspaceId: input.workspaceId,
|
|
346
350
|
callerPrincipalId: input.callerPrincipalId,
|
|
347
|
-
permissions: [
|
|
351
|
+
permissions: [ROLE_MANAGE],
|
|
348
352
|
});
|
|
349
353
|
const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
|
|
350
354
|
if (!target)
|
|
@@ -377,7 +381,7 @@ export async function updatePolicy(required, optional = {}) {
|
|
|
377
381
|
deps,
|
|
378
382
|
workspaceId: input.workspaceId,
|
|
379
383
|
callerPrincipalId: input.callerPrincipalId,
|
|
380
|
-
permissions: [
|
|
384
|
+
permissions: [ROLE_MANAGE],
|
|
381
385
|
});
|
|
382
386
|
const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
|
|
383
387
|
if (!target)
|
|
@@ -424,7 +428,7 @@ export async function deleteRole(required) {
|
|
|
424
428
|
deps,
|
|
425
429
|
workspaceId: input.workspaceId,
|
|
426
430
|
callerPrincipalId: input.callerPrincipalId,
|
|
427
|
-
permissions: [
|
|
431
|
+
permissions: [ROLE_MANAGE],
|
|
428
432
|
});
|
|
429
433
|
const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
|
|
430
434
|
if (!target)
|
|
@@ -468,7 +472,7 @@ export async function deletePolicy(required) {
|
|
|
468
472
|
deps,
|
|
469
473
|
workspaceId: input.workspaceId,
|
|
470
474
|
callerPrincipalId: input.callerPrincipalId,
|
|
471
|
-
permissions: [
|
|
475
|
+
permissions: [ROLE_MANAGE],
|
|
472
476
|
});
|
|
473
477
|
const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
|
|
474
478
|
if (!target)
|
|
@@ -515,7 +519,7 @@ export async function writePolicyPermission(required, optional = {}) {
|
|
|
515
519
|
deps,
|
|
516
520
|
workspaceId: input.workspaceId,
|
|
517
521
|
callerPrincipalId: input.callerPrincipalId,
|
|
518
|
-
permissions: [
|
|
522
|
+
permissions: [ROLE_MANAGE],
|
|
519
523
|
});
|
|
520
524
|
if (!isKnownPermission({ id: input.permission })) {
|
|
521
525
|
throw new PermissionUnknownError({ message: `permission '${input.permission}' is not in the registered catalog` });
|
|
@@ -580,7 +584,7 @@ export async function removePolicyPermission(required) {
|
|
|
580
584
|
deps,
|
|
581
585
|
workspaceId: input.workspaceId,
|
|
582
586
|
callerPrincipalId: input.callerPrincipalId,
|
|
583
|
-
permissions: [
|
|
587
|
+
permissions: [ROLE_MANAGE],
|
|
584
588
|
});
|
|
585
589
|
const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
|
|
586
590
|
if (!policy)
|
|
@@ -16,6 +16,7 @@ import { parseIdentityToolInput } from "./agent-tool-input.js";
|
|
|
16
16
|
import { identityAgentToolCatalog } from "./agent-tools.js";
|
|
17
17
|
import { assertCallerHasAnyPermission, assignRole, attachPolicy, createPolicy, createRole, createUser } from "./grant-service.js";
|
|
18
18
|
import { deletePolicy, deleteRole, disablePrincipal, enablePrincipal, updatePolicy, updateRole, updateUser } from "./admin-crud-service.js";
|
|
19
|
+
const MUTATES_DURABLE_STATE = "mutates-durable-state";
|
|
19
20
|
const CATALOG_BY_ID = indexCatalogById({ catalog: identityAgentToolCatalog });
|
|
20
21
|
/**
|
|
21
22
|
* Assembles `auth-service.ts`'s `IdentityRepos` bag from {@link IdentityToolDeps}'s flat fields — a
|
|
@@ -62,34 +63,34 @@ export const identityDerivedRisk = new Map([
|
|
|
62
63
|
["identity_role_list", "none"],
|
|
63
64
|
["identity_policy_list", "none"],
|
|
64
65
|
// -> createUser (grant-service.ts): saves a principal row AND a users row (credential).
|
|
65
|
-
["identity_user_create",
|
|
66
|
+
["identity_user_create", MUTATES_DURABLE_STATE],
|
|
66
67
|
// -> updateUser (admin-crud-service.ts): users.save, email field only.
|
|
67
|
-
["identity_user_update_email",
|
|
68
|
+
["identity_user_update_email", MUTATES_DURABLE_STATE],
|
|
68
69
|
// -> updateUser (admin-crud-service.ts): users.save, username field only, plus the transactional audit.
|
|
69
|
-
["identity_user_change_username",
|
|
70
|
+
["identity_user_change_username", MUTATES_DURABLE_STATE],
|
|
70
71
|
// -> disablePrincipal (admin-crud-service.ts): status flip, guarded by the owner floor. See docs/decisions/DR-007-workspace-and-owner-floors.md.
|
|
71
|
-
["identity_user_disable",
|
|
72
|
+
["identity_user_disable", MUTATES_DURABLE_STATE],
|
|
72
73
|
// -> enablePrincipal (admin-crud-service.ts): status flip back to active.
|
|
73
|
-
["identity_user_enable",
|
|
74
|
+
["identity_user_enable", MUTATES_DURABLE_STATE],
|
|
74
75
|
// -> createRole (grant-service.ts): roles.save, always isBuiltin=false.
|
|
75
|
-
["identity_role_create",
|
|
76
|
+
["identity_role_create", MUTATES_DURABLE_STATE],
|
|
76
77
|
// -> assignRole (grant-service.ts): principalRoles.save behind the grant clamp. The one. See docs/decisions/DR-007-workspace-and-owner-floors.md.
|
|
77
78
|
// identity tool that confers permissions, hence the heaviest of these.
|
|
78
|
-
["identity_role_assign",
|
|
79
|
+
["identity_role_assign", MUTATES_DURABLE_STATE],
|
|
79
80
|
// -> updateRole (admin-crud-service.ts): roles.save, refuses built-ins.
|
|
80
|
-
["identity_role_rename",
|
|
81
|
+
["identity_role_rename", MUTATES_DURABLE_STATE],
|
|
81
82
|
// -> deleteRole (admin-crud-service.ts): roles.delete, refuses built-ins and referenced roles.
|
|
82
|
-
["identity_role_delete",
|
|
83
|
+
["identity_role_delete", MUTATES_DURABLE_STATE],
|
|
83
84
|
// -> createPolicy (grant-service.ts): policies.save, always isBuiltin=false/isFrozen=false.
|
|
84
|
-
["identity_policy_create",
|
|
85
|
+
["identity_policy_create", MUTATES_DURABLE_STATE],
|
|
85
86
|
// -> updatePolicy (admin-crud-service.ts): policies.save, refuses built-in/frozen.
|
|
86
|
-
["identity_policy_update",
|
|
87
|
+
["identity_policy_update", MUTATES_DURABLE_STATE],
|
|
87
88
|
// -> deletePolicy (admin-crud-service.ts): policyPermissions.deleteByPolicyId + policies.delete,
|
|
88
89
|
// refuses built-in/frozen and still-referenced policies.
|
|
89
|
-
["identity_policy_delete",
|
|
90
|
+
["identity_policy_delete", MUTATES_DURABLE_STATE],
|
|
90
91
|
// -> attachPolicy (grant-service.ts): principalPolicies.save behind the grant clamp — the. See docs/decisions/DR-007-workspace-and-owner-floors.md.
|
|
91
92
|
// direct-grant counterpart to identity_role_assign, same weight.
|
|
92
|
-
["identity_policy_attach",
|
|
93
|
+
["identity_policy_attach", MUTATES_DURABLE_STATE],
|
|
93
94
|
]);
|
|
94
95
|
/**
|
|
95
96
|
* Upper bound on the roster `identity_user_list` will fan out over.
|
|
@@ -297,9 +298,11 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
297
298
|
identity_user_change_username: async (ctx) => {
|
|
298
299
|
const input = identityInput({ toolId: "identity_user_change_username", input: ctx.input });
|
|
299
300
|
const principalId = input.req("principalId");
|
|
300
|
-
const { user } = await updateUser({
|
|
301
|
+
const { user } = await updateUser({
|
|
302
|
+
deps: serviceDeps(), input: {
|
|
301
303
|
workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
|
|
302
|
-
}
|
|
304
|
+
}
|
|
305
|
+
}, { username: input.req("username") });
|
|
303
306
|
const principal = await routeDeps.principalRepo.findById({ workspaceId: routeDeps.workspaceId, id: principalId });
|
|
304
307
|
if (!principal)
|
|
305
308
|
throw new Error(`principal '${principalId}' was not found`);
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
const USER_MANAGE = "user.manage";
|
|
2
|
+
const ROLE_MANAGE = "role.manage";
|
|
3
|
+
const MUTATES_DURABLE_STATE = "mutates-durable-state";
|
|
1
4
|
/** A workspace-scoped principal id, as returned by `identity_user_list` or `identity_user_create`. */
|
|
2
5
|
const PRINCIPAL_ID_SCHEMA = {
|
|
3
6
|
type: "string",
|
|
@@ -50,28 +53,28 @@ export const identityAgentToolCatalog = [
|
|
|
50
53
|
name: "identity_user_list",
|
|
51
54
|
description: "Lists the workspace's human operator users with their principalId, username, status, and assigned role ids. Read-only. Call this to find a user's principalId before updating, disabling, enabling, or assigning a role to them. There is no password-reset tool. The owner resets a user's password from the Users screen in the admin (Reset password).",
|
|
52
55
|
sideEffects: "none",
|
|
53
|
-
authorization: { permission:
|
|
56
|
+
authorization: { permission: USER_MANAGE, orPermission: "member.manage" },
|
|
54
57
|
inputSchema: NO_INPUT_SCHEMA,
|
|
55
58
|
},
|
|
56
59
|
{
|
|
57
60
|
name: "identity_role_list",
|
|
58
61
|
description: "Lists the workspace's roles (the four built-ins plus any custom ones) with their id, name, and isBuiltin flag. Read-only. Call this to find a roleId before assigning, renaming, or deleting a role.",
|
|
59
62
|
sideEffects: "none",
|
|
60
|
-
authorization: { permission:
|
|
63
|
+
authorization: { permission: ROLE_MANAGE },
|
|
61
64
|
inputSchema: NO_INPUT_SCHEMA,
|
|
62
65
|
},
|
|
63
66
|
{
|
|
64
67
|
name: "identity_policy_list",
|
|
65
68
|
description: "Lists the workspace's policies (built-ins plus any custom ones) with their id, name, description, isBuiltin, and isFrozen flags. Read-only. Call this to find a policyId before renaming, deleting, or attaching a policy.",
|
|
66
69
|
sideEffects: "none",
|
|
67
|
-
authorization: { permission:
|
|
70
|
+
authorization: { permission: ROLE_MANAGE },
|
|
68
71
|
inputSchema: NO_INPUT_SCHEMA,
|
|
69
72
|
},
|
|
70
73
|
{
|
|
71
74
|
name: "identity_user_create",
|
|
72
75
|
description: "Creates a new human operator user, minting a new principal and its credential row together. The new user starts with NO roles and therefore no permissions — grant access afterwards with identity_role_assign. Fails if the username is already taken.",
|
|
73
|
-
sideEffects:
|
|
74
|
-
authorization: { permission:
|
|
76
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
77
|
+
authorization: { permission: USER_MANAGE },
|
|
75
78
|
inputSchema: {
|
|
76
79
|
type: "object",
|
|
77
80
|
additionalProperties: false,
|
|
@@ -94,8 +97,8 @@ export const identityAgentToolCatalog = [
|
|
|
94
97
|
{
|
|
95
98
|
name: "identity_user_update_email",
|
|
96
99
|
description: "Sets or clears an existing user's email address. Requires user.manage for other operator users; member.manage permits only editing your own email. Only an owner may edit an owner account. Omitting 'email' (or passing an empty string) CLEARS the stored value rather than leaving it unchanged. Username and password are not editable through this tool. There is no password-reset tool. The owner resets a user's password from the Users screen in the admin (Reset password).",
|
|
97
|
-
sideEffects:
|
|
98
|
-
authorization: { permission:
|
|
100
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
101
|
+
authorization: { permission: USER_MANAGE, orPermission: "member.manage" },
|
|
99
102
|
inputSchema: {
|
|
100
103
|
type: "object",
|
|
101
104
|
additionalProperties: false,
|
|
@@ -109,33 +112,37 @@ export const identityAgentToolCatalog = [
|
|
|
109
112
|
{
|
|
110
113
|
name: "identity_user_change_username",
|
|
111
114
|
description: "Changes an operator user's login username. Call after finding their principalId with identity_user_list. Returns { user } with the normalized username; email, password and existing sessions stay unchanged. Requires user.manage even for your own profile; only owners may rename owner accounts. Fails for blank names, missing users, users in Trash, or names reserved by any user including Trash. Choose a different name or restore/purge the conflicting trashed user before retrying.",
|
|
112
|
-
sideEffects:
|
|
113
|
-
authorization: { permission:
|
|
115
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
116
|
+
authorization: { permission: USER_MANAGE },
|
|
114
117
|
inputSchema: {
|
|
115
118
|
type: "object", additionalProperties: false, required: ["principalId", "username"],
|
|
116
|
-
properties: {
|
|
117
|
-
|
|
119
|
+
properties: {
|
|
120
|
+
principalId: PRINCIPAL_ID_SCHEMA, username: {
|
|
121
|
+
type: "string", minLength: 1,
|
|
122
|
+
description: "New login name. Trimmed, lowercased and Unicode NFC normalized; unique per workspace, including users in Trash."
|
|
123
|
+
}
|
|
124
|
+
},
|
|
118
125
|
},
|
|
119
126
|
},
|
|
120
127
|
{
|
|
121
128
|
name: "identity_user_disable",
|
|
122
129
|
description: "Disables a user, revoking their access. Principals are never hard-deleted because the audit trail references them, so this is how a user is removed. Refuses to disable the seeded owner, and refuses any disable that would leave the workspace with no active owner. Already-disabled is a no-op.",
|
|
123
|
-
sideEffects:
|
|
124
|
-
authorization: { permission:
|
|
130
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
131
|
+
authorization: { permission: USER_MANAGE },
|
|
125
132
|
inputSchema: PRINCIPAL_ONLY_INPUT_SCHEMA,
|
|
126
133
|
},
|
|
127
134
|
{
|
|
128
135
|
name: "identity_user_enable",
|
|
129
136
|
description: "Re-enables a previously disabled human user. Already-active is a no-op. Only kind='user' principals can be enabled this way.",
|
|
130
|
-
sideEffects:
|
|
131
|
-
authorization: { permission:
|
|
137
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
138
|
+
authorization: { permission: USER_MANAGE },
|
|
132
139
|
inputSchema: PRINCIPAL_ONLY_INPUT_SCHEMA,
|
|
133
140
|
},
|
|
134
141
|
{
|
|
135
142
|
name: "identity_role_create",
|
|
136
143
|
description: "Creates a new custom role. The role starts with no policies attached and therefore confers no permissions until one is attached, which is not an agent-callable operation.",
|
|
137
|
-
sideEffects:
|
|
138
|
-
authorization: { permission:
|
|
144
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
145
|
+
authorization: { permission: ROLE_MANAGE },
|
|
139
146
|
inputSchema: {
|
|
140
147
|
type: "object",
|
|
141
148
|
additionalProperties: false,
|
|
@@ -146,8 +153,8 @@ export const identityAgentToolCatalog = [
|
|
|
146
153
|
{
|
|
147
154
|
name: "identity_role_assign",
|
|
148
155
|
description: "Assigns an existing role to an existing user. This is the step that actually grants permissions. The caller can only confer permissions it already holds itself unconstrained, so assigning a role more powerful than the caller's own is refused. There is no tool to remove a role from a user, and no unassign operation exists anywhere in this system yet; tell the owner rather than trying another tool.",
|
|
149
|
-
sideEffects:
|
|
150
|
-
authorization: { permission:
|
|
156
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
157
|
+
authorization: { permission: ROLE_MANAGE },
|
|
151
158
|
inputSchema: {
|
|
152
159
|
type: "object",
|
|
153
160
|
additionalProperties: false,
|
|
@@ -158,8 +165,8 @@ export const identityAgentToolCatalog = [
|
|
|
158
165
|
{
|
|
159
166
|
name: "identity_role_rename",
|
|
160
167
|
description: "Renames a custom role. Built-in roles cannot be renamed — relabelling 'viewer' to read as trusted is a social-engineering variant of privilege escalation, so it is refused.",
|
|
161
|
-
sideEffects:
|
|
162
|
-
authorization: { permission:
|
|
168
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
169
|
+
authorization: { permission: ROLE_MANAGE },
|
|
163
170
|
inputSchema: {
|
|
164
171
|
type: "object",
|
|
165
172
|
additionalProperties: false,
|
|
@@ -170,8 +177,8 @@ export const identityAgentToolCatalog = [
|
|
|
170
177
|
{
|
|
171
178
|
name: "identity_role_delete",
|
|
172
179
|
description: "Deletes a custom role. Built-in roles cannot be deleted. A role still assigned to at least one user is refused — this system has no unassign operation, so a role in use cannot be removed at all.",
|
|
173
|
-
sideEffects:
|
|
174
|
-
authorization: { permission:
|
|
180
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
181
|
+
authorization: { permission: ROLE_MANAGE },
|
|
175
182
|
inputSchema: {
|
|
176
183
|
type: "object",
|
|
177
184
|
additionalProperties: false,
|
|
@@ -182,8 +189,8 @@ export const identityAgentToolCatalog = [
|
|
|
182
189
|
{
|
|
183
190
|
name: "identity_policy_create",
|
|
184
191
|
description: "Creates a new custom policy. The policy starts with no permissions and therefore confers none until permissions are added, which is not an agent-callable operation — identity_policy_write_permission is deliberately not wired (see this file's own header comment). Attaching a freshly created, still-empty policy to anyone is a no-op.",
|
|
185
|
-
sideEffects:
|
|
186
|
-
authorization: { permission:
|
|
192
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
193
|
+
authorization: { permission: ROLE_MANAGE },
|
|
187
194
|
inputSchema: {
|
|
188
195
|
type: "object",
|
|
189
196
|
additionalProperties: false,
|
|
@@ -197,8 +204,8 @@ export const identityAgentToolCatalog = [
|
|
|
197
204
|
{
|
|
198
205
|
name: "identity_policy_update",
|
|
199
206
|
description: "Renames and/or re-describes a custom policy. Built-in and frozen policies are refused. At least one of 'name'/'description' must be supplied.",
|
|
200
|
-
sideEffects:
|
|
201
|
-
authorization: { permission:
|
|
207
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
208
|
+
authorization: { permission: ROLE_MANAGE },
|
|
202
209
|
inputSchema: {
|
|
203
210
|
type: "object",
|
|
204
211
|
additionalProperties: false,
|
|
@@ -213,8 +220,8 @@ export const identityAgentToolCatalog = [
|
|
|
213
220
|
{
|
|
214
221
|
name: "identity_policy_delete",
|
|
215
222
|
description: "Deletes a custom policy. Built-in and frozen policies cannot be deleted. A policy still referenced by at least one role or attached to at least one user is refused — deletion fails safe (it only ever removes access, never orphans a reference) exactly like identity_role_delete.",
|
|
216
|
-
sideEffects:
|
|
217
|
-
authorization: { permission:
|
|
223
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
224
|
+
authorization: { permission: ROLE_MANAGE },
|
|
218
225
|
inputSchema: {
|
|
219
226
|
type: "object",
|
|
220
227
|
additionalProperties: false,
|
|
@@ -227,8 +234,8 @@ export const identityAgentToolCatalog = [
|
|
|
227
234
|
description:
|
|
228
235
|
// See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
|
|
229
236
|
"Attaches an existing policy directly to an existing user, granting every permission the policy carries. This is the direct-grant counterpart to identity_role_assign, targets exactly one named user per call, and carries the identical grant-authority clamp: the caller can only confer permissions it already holds itself unconstrained, so attaching a policy more powerful than the caller's own is refused. There is no detach operation.",
|
|
230
|
-
sideEffects:
|
|
231
|
-
authorization: { permission:
|
|
237
|
+
sideEffects: MUTATES_DURABLE_STATE,
|
|
238
|
+
authorization: { permission: ROLE_MANAGE },
|
|
232
239
|
inputSchema: {
|
|
233
240
|
type: "object",
|
|
234
241
|
additionalProperties: false,
|
|
@@ -14,7 +14,7 @@ export function newChallengeSecrets({ tokens, randomInt }, _options = {}) {
|
|
|
14
14
|
}
|
|
15
15
|
/** Exact ASCII shape; without the m flag, $ also refuses trailing line terminators. O(1). */
|
|
16
16
|
function isChallengeCode(code) {
|
|
17
|
-
return
|
|
17
|
+
return /^\d{6}$/.test(code);
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Hash the low-entropy code using the existing password hasher, never a fast digest.
|
|
@@ -38,8 +38,9 @@ export async function verifyChallengeCode({ code, hash, hasher }, _options = {})
|
|
|
38
38
|
}
|
|
39
39
|
/** Invalid counters or limits deny access; O(1), without changing the stored counter. */
|
|
40
40
|
function hasAttemptsRemaining(failedAttempts, maxAttempts) {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
if (!Number.isSafeInteger(failedAttempts) || failedAttempts < 0)
|
|
42
|
+
return false;
|
|
43
|
+
return Number.isSafeInteger(maxAttempts) && maxAttempts > 0 && failedAttempts < maxAttempts;
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
46
|
* Decide metadata eligibility only; callers must also verify the token/code and use the
|
package/dist/server/hasher.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
const requirePeer = createRequire(import.meta.url);
|
|
3
4
|
/** Resolve the optional native peer only when this adapter is used. */
|
|
4
5
|
export function loadArgon2Binding(_required) {
|
|
5
|
-
const native = requirePeer("argon2");
|
|
6
|
+
const native = loadOptionalPeer({ feature: 'Argon2 password hashing', dependency: 'argon2', load: () => requirePeer("argon2") });
|
|
6
7
|
return {
|
|
7
8
|
argon2id: native.argon2id,
|
|
8
9
|
hash: ({ password }, optional = {}) => native.hash(password, { ...DEFAULT_OPTIONS, type: native.argon2id, ...optional }),
|
|
@@ -35,13 +35,14 @@ export function createTransactionalInMemoryIdentityRepos(required) {
|
|
|
35
35
|
release();
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
const snapshot = (repo) => {
|
|
39
|
+
const rows = repo.snapshotRows({});
|
|
40
|
+
return { restore: () => repo.restoreRows({ rows }) };
|
|
41
|
+
};
|
|
38
42
|
const transactions = {
|
|
39
43
|
run: ({ execute }) => exclusive(async () => {
|
|
40
44
|
// Every store was checked to be InMemoryIdentityRows when the bag was bound above.
|
|
41
|
-
const snapshots = stores.map(
|
|
42
|
-
const rows = repo.snapshotRows({});
|
|
43
|
-
return { restore: () => repo.restoreRows({ rows }) };
|
|
44
|
-
});
|
|
45
|
+
const snapshots = stores.map(snapshot);
|
|
45
46
|
try {
|
|
46
47
|
return await execute();
|
|
47
48
|
}
|
|
@@ -52,17 +53,20 @@ export function createTransactionalInMemoryIdentityRepos(required) {
|
|
|
52
53
|
}
|
|
53
54
|
}),
|
|
54
55
|
};
|
|
56
|
+
function queuedMember(member, target) {
|
|
57
|
+
return (...args) => exclusive(async () => {
|
|
58
|
+
// Detach inputs/outputs so a row reference outside the queue cannot mutate stored state.
|
|
59
|
+
const result = await Reflect.apply(member, target, structuredClone(args));
|
|
60
|
+
return structuredClone(result);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
55
63
|
function wrap(repo) {
|
|
56
64
|
return new Proxy(repo, {
|
|
57
65
|
get(target, property, receiver) {
|
|
58
66
|
const member = Reflect.get(target, property, receiver);
|
|
59
67
|
if (typeof member !== 'function')
|
|
60
68
|
return member;
|
|
61
|
-
return (
|
|
62
|
-
// Detach inputs/outputs so a row reference outside the queue cannot mutate stored state.
|
|
63
|
-
const result = await Reflect.apply(member, target, structuredClone(args));
|
|
64
|
-
return structuredClone(result);
|
|
65
|
-
});
|
|
69
|
+
return queuedMember(member, target);
|
|
66
70
|
},
|
|
67
71
|
});
|
|
68
72
|
}
|
|
@@ -40,6 +40,9 @@ export declare class InMemoryPrincipalRepo extends InMemoryIdentityRows<Principa
|
|
|
40
40
|
workspaceId: string;
|
|
41
41
|
}): Promise<PrincipalRecord[]>;
|
|
42
42
|
save(record: PrincipalRecord): Promise<void>;
|
|
43
|
+
insertIfAbsent({ record }: {
|
|
44
|
+
record: PrincipalRecord;
|
|
45
|
+
}, _optional?: Record<string, never>): Promise<PrincipalRecord>;
|
|
43
46
|
}
|
|
44
47
|
export declare class InMemoryUserRepo extends InMemoryIdentityRows<UserRecord> implements UserRepoPort {
|
|
45
48
|
constructor(_required: Record<string, never>, { initialRows }?: {
|
|
@@ -42,6 +42,14 @@ export class InMemoryPrincipalRepo extends InMemoryIdentityRows {
|
|
|
42
42
|
async save(record) {
|
|
43
43
|
upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
|
|
44
44
|
}
|
|
45
|
+
async insertIfAbsent({ record }, _optional = {}) {
|
|
46
|
+
// No await between lookup and insert: competing calls on this store cannot interleave.
|
|
47
|
+
const existing = this.rows.find(row => matchesRecordFields({ record: row, fields: { workspaceId: record.workspaceId, id: record.id } }));
|
|
48
|
+
if (existing)
|
|
49
|
+
return existing;
|
|
50
|
+
this.rows.push(record);
|
|
51
|
+
return record;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
export class InMemoryUserRepo extends InMemoryIdentityRows {
|
|
47
55
|
constructor(_required, { initialRows = [] } = {}) {
|