@jini-ai/user-management 0.2.0 → 0.3.1
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/CHANGELOG.md +43 -0
- package/dist/admin/people.rules.js +2 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/principal-kind-policy.d.ts +30 -0
- package/dist/core/principal-kind-policy.js +28 -0
- package/dist/core/types.d.ts +7 -2
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/members/Members.d.ts +28 -1
- package/dist/react/members/Members.js +14 -10
- package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
- package/dist/react/members/hooks/use-members.hooks.js +37 -9
- package/dist/react/members/rules.d.ts +31 -4
- package/dist/react/members/rules.js +17 -5
- package/dist/react/models.d.ts +3 -0
- package/dist/react/users/Users.d.ts +105 -6
- package/dist/react/users/Users.js +105 -43
- package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
- package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
- package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
- package/dist/react/users/hooks/use-users.hooks.js +192 -23
- package/dist/react/users/rules.d.ts +115 -8
- package/dist/react/users/rules.js +109 -6
- package/dist/server/admin-crud-service.js +4 -1
- package/dist/server/authorize.d.ts +9 -6
- package/dist/server/authorize.js +11 -3
- package/dist/server/permission-migrations.d.ts +35 -15
- package/dist/server/permission-migrations.js +16 -16
- package/dist/server/seed.js +6 -1
- package/package.json +15 -10
- package/dist/react/roles/Roles.d.ts +0 -100
- package/dist/react/roles/Roles.hooks.d.ts +0 -17
- package/dist/react/roles/Roles.hooks.js +0 -40
- package/dist/react/roles/Roles.js +0 -150
- package/dist/react/roles/hooks/use-roles.hooks.d.ts +0 -80
- package/dist/react/roles/hooks/use-roles.hooks.js +0 -303
- package/dist/react/roles/rules.d.ts +0 -35
- package/dist/react/roles/rules.js +0 -31
- package/dist/react/roles/text.d.ts +0 -27
- package/dist/react/roles/text.js +0 -13
|
@@ -1,33 +1,140 @@
|
|
|
1
1
|
import type { AdminIdentityUser } from "../models.js";
|
|
2
|
-
import type { Translate } from "@jini-ai/ui/panel-kit";
|
|
2
|
+
import type { Translate, QueryKey } from "@jini-ai/ui/panel-kit";
|
|
3
3
|
import type { RowMenuItem } from "@jini-ai/admin/react";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* @file Pure logic for the `users` feature — everything that computes a value rather than
|
|
6
|
+
* rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
|
|
7
|
+
* testable.
|
|
8
|
+
*
|
|
9
|
+
* Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
|
|
10
|
+
* error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
|
|
11
|
+
* split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
|
|
12
|
+
* user holds any grants of that kind). All three were previously closures or free functions inside
|
|
13
|
+
* the component, reachable only by rendering the full screen.
|
|
14
|
+
*
|
|
15
|
+
* `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
|
|
16
|
+
* read — matches the pre-migration `reload()`, which always refetched all three together.
|
|
17
|
+
*/
|
|
5
18
|
export declare const KEYS: {
|
|
6
19
|
list: QueryKey;
|
|
7
20
|
};
|
|
8
|
-
|
|
21
|
+
/** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
|
|
22
|
+
* shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
|
|
23
|
+
* "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
|
|
24
|
+
* `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
|
|
25
|
+
* deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
|
|
26
|
+
* joining the table above: its message comes from the error itself (`e.message`), not a fixed
|
|
27
|
+
* string, so it isn't a value a plain lookup can hold.
|
|
28
|
+
*
|
|
29
|
+
* `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
|
|
30
|
+
* leaking English into every non-`en` locale regardless of the caller's own translated fallback —
|
|
31
|
+
* the fallback was already threaded through `t()` at each call site, but these overrides were not.
|
|
32
|
+
* Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
|
|
33
|
+
export declare function describeUsersError({ error, fallback, translate }: {
|
|
9
34
|
error: unknown;
|
|
10
35
|
fallback: string;
|
|
11
36
|
translate: Translate;
|
|
12
|
-
}): string;
|
|
37
|
+
}, _optional?: Record<string, never>): string;
|
|
38
|
+
export { describeUsersError as describeApiError };
|
|
39
|
+
/** The callbacks a row menu needs. Passed in rather than imported, so this module stays free of
|
|
40
|
+
* state, and so a test can assert exactly which one a given row wires up (same convention as
|
|
41
|
+
* `posts/rules.ts`'s `PostRowMenuHandlers`). */
|
|
13
42
|
export interface UserRowMenuHandlers {
|
|
43
|
+
/** Asked only when `user.status === "active"` — opens the confirm dialog rather than acting. */
|
|
14
44
|
onRequestDisable: (user: AdminIdentityUser) => void;
|
|
45
|
+
/** Asked only when `user.status !== "active"` — fires immediately, no confirm (Enable was never
|
|
46
|
+
* confirm-gated). */
|
|
15
47
|
onEnable: (user: AdminIdentityUser) => void;
|
|
16
48
|
onManage: (user: AdminIdentityUser) => void;
|
|
17
49
|
onResetPassword: (user: AdminIdentityUser) => void;
|
|
50
|
+
/** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
|
|
51
|
+
* unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
|
|
52
|
+
* it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
|
|
53
|
+
* is true — see that function's doc comment. */
|
|
18
54
|
onRequestDelete: (user: AdminIdentityUser) => void;
|
|
19
55
|
}
|
|
20
|
-
export
|
|
56
|
+
export interface UserAccountCapabilities {
|
|
57
|
+
canDelete: boolean;
|
|
58
|
+
canDisable: boolean;
|
|
59
|
+
canEnable: boolean;
|
|
60
|
+
canResetPassword: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
|
|
63
|
+
export declare function userAccountCapabilities(required: {
|
|
64
|
+
user: AdminIdentityUser;
|
|
65
|
+
callerPrincipalId: string | null;
|
|
66
|
+
callerIsOwner: boolean;
|
|
67
|
+
canManageUserTrash: boolean;
|
|
68
|
+
roles: ReadonlyMap<string, {
|
|
69
|
+
name: string;
|
|
70
|
+
isBuiltin: boolean;
|
|
71
|
+
}>;
|
|
72
|
+
}, _optional?: Record<string, never>): UserAccountCapabilities;
|
|
73
|
+
export declare function isOwnerAccount(required: {
|
|
74
|
+
user: AdminIdentityUser;
|
|
75
|
+
roles: ReadonlyMap<string, {
|
|
76
|
+
name: string;
|
|
77
|
+
isBuiltin: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
}, _optional?: Record<string, never>): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
|
|
82
|
+
* Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
|
|
83
|
+
* only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
|
|
84
|
+
* `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
|
|
85
|
+
* row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
|
|
86
|
+
* `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
|
|
87
|
+
*
|
|
88
|
+
* Disable/Enable share a single "toggle" item (label follows status, same shape as
|
|
89
|
+
* `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
|
|
90
|
+
* immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
|
|
91
|
+
* `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
|
|
92
|
+
* whatever row action is currently in flight, read fresh at click time.
|
|
93
|
+
*
|
|
94
|
+
* "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
|
|
95
|
+
* (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
|
|
96
|
+
* as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
|
|
97
|
+
* that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
|
|
98
|
+
* state the operator cannot see while the menu that shows it is open. A static label sidesteps that
|
|
99
|
+
* without losing any capability.
|
|
100
|
+
*
|
|
101
|
+
* "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
|
|
102
|
+
* Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
|
|
103
|
+
*
|
|
104
|
+
* @complexity Time/space: O(1) — at most four entries, no iteration.
|
|
105
|
+
*/
|
|
106
|
+
export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities }: {
|
|
21
107
|
user: AdminIdentityUser;
|
|
22
108
|
toggleSaving: boolean;
|
|
23
109
|
handlers: UserRowMenuHandlers;
|
|
24
110
|
translate: Translate;
|
|
25
111
|
canDelete: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
112
|
+
capabilities?: boolean | UserAccountCapabilities | undefined;
|
|
113
|
+
}, _optional?: Record<string, never>): RowMenuItem[];
|
|
114
|
+
/**
|
|
115
|
+
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
116
|
+
* user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
|
|
117
|
+
* this already-computed value, per this feature's extraction rule).
|
|
118
|
+
*
|
|
119
|
+
* A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
|
|
120
|
+
* grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
|
|
121
|
+
* something is wrong instead of an undercount with no explanation.
|
|
122
|
+
*
|
|
123
|
+
* @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
|
|
124
|
+
*/
|
|
125
|
+
export declare function grantOptionLabel(required: {
|
|
126
|
+
option: {
|
|
127
|
+
name: string;
|
|
128
|
+
isBuiltin: boolean;
|
|
129
|
+
};
|
|
130
|
+
translate: (key: string) => string;
|
|
131
|
+
}, _optional?: Record<string, never>): string;
|
|
132
|
+
export declare function formatGrantLabel({ ids, byId, translate }: {
|
|
28
133
|
ids: readonly string[];
|
|
29
134
|
byId: ReadonlyMap<string, {
|
|
30
135
|
name: string;
|
|
136
|
+
isBuiltin?: boolean;
|
|
31
137
|
}>;
|
|
32
|
-
|
|
138
|
+
translate?: Translate | undefined;
|
|
139
|
+
}, _optional?: Record<string, never>): string | null;
|
|
33
140
|
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -1,20 +1,101 @@
|
|
|
1
1
|
import { describeIdentityError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file Pure logic for the `users` feature — everything that computes a value rather than
|
|
4
|
+
* rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
|
|
5
|
+
* testable.
|
|
6
|
+
*
|
|
7
|
+
* Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
|
|
8
|
+
* error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
|
|
9
|
+
* split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
|
|
10
|
+
* user holds any grants of that kind). All three were previously closures or free functions inside
|
|
11
|
+
* the component, reachable only by rendering the full screen.
|
|
12
|
+
*
|
|
13
|
+
* `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
|
|
14
|
+
* read — matches the pre-migration `reload()`, which always refetched all three together.
|
|
15
|
+
*/
|
|
2
16
|
export const KEYS = {
|
|
3
17
|
list: ["users-roles-policies"],
|
|
4
18
|
};
|
|
19
|
+
/** Server error `code` -> a plain-language prefix, for every code on this screen whose message is
|
|
20
|
+
* a fixed string. Keyed by the same closed set of `ApiError` codes the old if-chain checked, in a
|
|
21
|
+
* flat lookup rather than sequential branches — this is what actually lowered the function's
|
|
22
|
+
* cognitive score (11 -> under the ceiling): the codes are a closed set of *literal string* keys,
|
|
23
|
+
* not a discriminated union, so there is no TypeScript exhaustiveness to lose by using a table
|
|
24
|
+
* (contrast `translateRunAgentPayload`'s `switch`, which stays a `switch` for exactly that reason).
|
|
25
|
+
* Values are the English source strings — `t()`'s own keys — not yet localized; `describeApiError`
|
|
26
|
+
* below is what translates them. */
|
|
5
27
|
const STATIC_ERROR_MESSAGES = {
|
|
6
28
|
GRANT_EXCEEDS_ISSUER: "You cannot grant a permission you do not hold.",
|
|
7
29
|
FORBIDDEN: "You do not have permission to do that.",
|
|
8
30
|
RESOURCE_CONFLICT: "That username is already in use.",
|
|
9
31
|
OWNER_REQUIRED: "The workspace must keep at least one active owner.",
|
|
32
|
+
// Delete-user plan v2 (2026-09-24), Slice 3/decision 7 — the DELETE route's own 409s.
|
|
10
33
|
SELF_DELETE: "You cannot delete your own account.",
|
|
11
34
|
USER_IN_TRASH: "This user is in the Trash; restore them first.",
|
|
12
35
|
USERNAME_IN_TRASH: "A user with this username is in the Trash; restore or delete them permanently first.",
|
|
13
36
|
};
|
|
14
|
-
|
|
37
|
+
/** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
|
|
38
|
+
* shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
|
|
39
|
+
* "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
|
|
40
|
+
* `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
|
|
41
|
+
* deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
|
|
42
|
+
* joining the table above: its message comes from the error itself (`e.message`), not a fixed
|
|
43
|
+
* string, so it isn't a value a plain lookup can hold.
|
|
44
|
+
*
|
|
45
|
+
* `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
|
|
46
|
+
* leaking English into every non-`en` locale regardless of the caller's own translated fallback —
|
|
47
|
+
* the fallback was already threaded through `t()` at each call site, but these overrides were not.
|
|
48
|
+
* Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
|
|
49
|
+
export function describeUsersError({ error, fallback, translate }, _optional = {}) {
|
|
15
50
|
return describeIdentityError({ error, fallback, translate }, { messages: STATIC_ERROR_MESSAGES });
|
|
16
51
|
}
|
|
17
|
-
export
|
|
52
|
+
export { describeUsersError as describeApiError };
|
|
53
|
+
/** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
|
|
54
|
+
export function userAccountCapabilities(required, _optional = {}) {
|
|
55
|
+
const { user, callerPrincipalId, callerIsOwner, canManageUserTrash, roles } = required;
|
|
56
|
+
const known = callerPrincipalId !== null;
|
|
57
|
+
const protectedAccount = user.isProtectedAccount ?? (user.roleIds.some(id => {
|
|
58
|
+
const role = roles.get(id);
|
|
59
|
+
return !role || (role.isBuiltin && (role.name === "admin" || role.name === "owner"));
|
|
60
|
+
}));
|
|
61
|
+
const mayAct = known && (callerIsOwner || !protectedAccount);
|
|
62
|
+
const destructive = mayAct && callerPrincipalId !== user.principalId;
|
|
63
|
+
return { canDelete: destructive && canManageUserTrash, canDisable: destructive,
|
|
64
|
+
canEnable: mayAct, canResetPassword: mayAct };
|
|
65
|
+
}
|
|
66
|
+
export function isOwnerAccount(required, _optional = {}) {
|
|
67
|
+
return required.user.isOwner ?? required.user.roleIds.some(id => {
|
|
68
|
+
const role = required.roles.get(id);
|
|
69
|
+
return role?.isBuiltin === true && role.name === "owner";
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
|
|
74
|
+
* Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
|
|
75
|
+
* only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
|
|
76
|
+
* `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
|
|
77
|
+
* row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
|
|
78
|
+
* `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
|
|
79
|
+
*
|
|
80
|
+
* Disable/Enable share a single "toggle" item (label follows status, same shape as
|
|
81
|
+
* `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
|
|
82
|
+
* immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
|
|
83
|
+
* `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
|
|
84
|
+
* whatever row action is currently in flight, read fresh at click time.
|
|
85
|
+
*
|
|
86
|
+
* "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
|
|
87
|
+
* (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
|
|
88
|
+
* as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
|
|
89
|
+
* that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
|
|
90
|
+
* state the operator cannot see while the menu that shows it is open. A static label sidesteps that
|
|
91
|
+
* without losing any capability.
|
|
92
|
+
*
|
|
93
|
+
* "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
|
|
94
|
+
* Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
|
|
95
|
+
*
|
|
96
|
+
* @complexity Time/space: O(1) — at most four entries, no iteration.
|
|
97
|
+
*/
|
|
98
|
+
export function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities = canDelete }, _optional = {}) {
|
|
18
99
|
const items = [
|
|
19
100
|
{
|
|
20
101
|
key: "toggle",
|
|
@@ -43,7 +124,10 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
|
|
|
43
124
|
onSelect: () => handlers.onResetPassword(user),
|
|
44
125
|
},
|
|
45
126
|
];
|
|
46
|
-
|
|
127
|
+
const permissions = typeof capabilities === "boolean"
|
|
128
|
+
? { canDelete: capabilities, canDisable: true, canEnable: true, canResetPassword: true }
|
|
129
|
+
: capabilities;
|
|
130
|
+
if (permissions.canDelete) {
|
|
47
131
|
items.push({
|
|
48
132
|
key: "delete",
|
|
49
133
|
label: translate("Delete"),
|
|
@@ -51,11 +135,30 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
|
|
|
51
135
|
onSelect: () => handlers.onRequestDelete(user),
|
|
52
136
|
});
|
|
53
137
|
}
|
|
54
|
-
return items
|
|
138
|
+
return items.filter(item => item.key === "toggle"
|
|
139
|
+
? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
|
|
140
|
+
: item.key !== "reset-password" || permissions.canResetPassword);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
144
|
+
* user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
|
|
145
|
+
* this already-computed value, per this feature's extraction rule).
|
|
146
|
+
*
|
|
147
|
+
* A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
|
|
148
|
+
* grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
|
|
149
|
+
* something is wrong instead of an undercount with no explanation.
|
|
150
|
+
*
|
|
151
|
+
* @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
|
|
152
|
+
*/
|
|
153
|
+
export function grantOptionLabel(required, _optional = {}) {
|
|
154
|
+
return required.option.isBuiltin && required.option.name === "owner" ? required.translate("Owner") : required.option.name;
|
|
55
155
|
}
|
|
56
|
-
export function formatGrantLabel({ ids, byId }) {
|
|
156
|
+
export function formatGrantLabel({ ids, byId, translate = (key) => key }, _optional = {}) {
|
|
57
157
|
if (ids.length === 0)
|
|
58
158
|
return null;
|
|
59
|
-
return ids.map(
|
|
159
|
+
return ids.map(id => {
|
|
160
|
+
const role = byId.get(id);
|
|
161
|
+
return role?.isBuiltin && role.name === "owner" ? translate("Owner") : role?.name ?? id;
|
|
162
|
+
}).join(", ");
|
|
60
163
|
}
|
|
61
164
|
//# sourceMappingURL=rules.js.map
|
|
@@ -3,6 +3,7 @@ import { inIdentityTransaction } from "./identity-transaction.js";
|
|
|
3
3
|
import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
|
|
4
4
|
import { validatePasswordPolicy } from "./password-policy.js";
|
|
5
5
|
import { isKnownPermission } from "../core/permissions.js";
|
|
6
|
+
import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
|
|
6
7
|
import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
|
|
7
8
|
/**
|
|
8
9
|
* @file An amendment that completes the users/roles/policies admin CRUD surface
|
|
@@ -554,7 +555,9 @@ export async function removePolicyPermission(required) {
|
|
|
554
555
|
async function countActiveOwnerWildcardPrincipals(required) {
|
|
555
556
|
const { deps, workspaceId } = required;
|
|
556
557
|
const principals = await deps.repos.principals.list({ workspaceId });
|
|
557
|
-
|
|
558
|
+
// A kind barred from operator permissions (a site `member`) can never act as an owner, so it is
|
|
559
|
+
// not counted even if a `*` row reaches it — and members never cost a grant read here.
|
|
560
|
+
const activePrincipals = principals.filter((principal) => principal.status === "active" && principalKindMayExercisePermission({ kind: principal.kind, permission: "*" }));
|
|
558
561
|
const flags = await Promise.all(activePrincipals.map((principal) => principalHoldsOwnerWildcard({ deps, workspaceId, principalId: principal.id })));
|
|
559
562
|
return flags.filter(Boolean).length;
|
|
560
563
|
}
|
|
@@ -19,8 +19,8 @@ import type { PolicyPermissionRecord } from "../core/types.js";
|
|
|
19
19
|
*
|
|
20
20
|
* Architectural role:
|
|
21
21
|
* Implements the matcher precedence in behavior.spec §1.1 exactly:
|
|
22
|
-
* disabled-principal short-circuit >
|
|
23
|
-
* fail-closed default. Fail-closed extensibility: a non-null
|
|
22
|
+
* disabled-principal short-circuit > principal-kind bar > owner wildcard >
|
|
23
|
+
* exact matching row > fail-closed default. Fail-closed extensibility: a non-null
|
|
24
24
|
* `constraintJson` is never treated as unconstrained, and a `resourceType`
|
|
25
25
|
* mismatch/absence is never treated as a global grant.
|
|
26
26
|
* See docs/decisions/DR-001-identity-and-session-boundary.md.
|
|
@@ -34,7 +34,7 @@ export interface AuthorizeContext {
|
|
|
34
34
|
/** `authorize`'s result — always fail-closed on `allowed: false`. See docs/decisions/DR-001-identity-and-session-boundary.md. */
|
|
35
35
|
export interface AuthorizeResult {
|
|
36
36
|
allowed: boolean;
|
|
37
|
-
/** Machine-readable reason: `principal_disabled | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
|
|
37
|
+
/** Machine-readable reason: `principal_disabled | principal_kind_denied | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
|
|
38
38
|
reason: string;
|
|
39
39
|
}
|
|
40
40
|
/** Repos `authorize()`/`resolveEffectivePermissions()` read. Not a port bag with alternate adapters — see file header. */
|
|
@@ -68,10 +68,13 @@ export declare function resolveEffectivePermissions(required: {
|
|
|
68
68
|
*
|
|
69
69
|
* Precedence (behavior.spec §1.1, highest to lowest):
|
|
70
70
|
* 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
|
|
71
|
-
* 2.
|
|
72
|
-
*
|
|
71
|
+
* 2. A principal whose kind may not exercise `permission` (today: `member`, see
|
|
72
|
+
* `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
|
|
73
|
+
* including the owner wildcard).
|
|
74
|
+
* 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
|
|
75
|
+
* 4. An exact matching row (permission + workspace + resource-scope + no
|
|
73
76
|
* constraint) -> `true`, `matched`.
|
|
74
|
-
*
|
|
77
|
+
* 5. Fail-closed default -> `false`, with the most specific diagnostic reason
|
|
75
78
|
* available among same-permission candidates (`unconstrained_deny` over
|
|
76
79
|
* `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
|
|
77
80
|
* reasonable tie-break; the spec only pins each reason in isolation, see
|
package/dist/server/authorize.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
|
|
1
2
|
/**
|
|
2
3
|
* Resolve a principal's effective permission rows: the union of (a) its
|
|
3
4
|
* roles' policies' permissions and (b) policies attached directly via
|
|
@@ -46,10 +47,13 @@ function matchesRow(row, permission, context) {
|
|
|
46
47
|
*
|
|
47
48
|
* Precedence (behavior.spec §1.1, highest to lowest):
|
|
48
49
|
* 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
|
|
49
|
-
* 2.
|
|
50
|
-
*
|
|
50
|
+
* 2. A principal whose kind may not exercise `permission` (today: `member`, see
|
|
51
|
+
* `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
|
|
52
|
+
* including the owner wildcard).
|
|
53
|
+
* 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
|
|
54
|
+
* 4. An exact matching row (permission + workspace + resource-scope + no
|
|
51
55
|
* constraint) -> `true`, `matched`.
|
|
52
|
-
*
|
|
56
|
+
* 5. Fail-closed default -> `false`, with the most specific diagnostic reason
|
|
53
57
|
* available among same-permission candidates (`unconstrained_deny` over
|
|
54
58
|
* `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
|
|
55
59
|
* reasonable tie-break; the spec only pins each reason in isolation, see
|
|
@@ -68,6 +72,10 @@ export async function authorize(required, optional = {}) {
|
|
|
68
72
|
if (!principal || principal.status === "disabled") {
|
|
69
73
|
return { allowed: false, reason: "principal_disabled" };
|
|
70
74
|
}
|
|
75
|
+
// Ahead of every grant read: a barred kind is denied even when a `*` row reaches it.
|
|
76
|
+
if (!principalKindMayExercisePermission({ kind: principal.kind, permission })) {
|
|
77
|
+
return { allowed: false, reason: "principal_kind_denied" };
|
|
78
|
+
}
|
|
71
79
|
const effectiveRows = await resolveEffectivePermissions({
|
|
72
80
|
deps,
|
|
73
81
|
principalId,
|
|
@@ -16,11 +16,17 @@ import type { IdentityTransactionPort, PolicyPermissionRepoPort, PolicyRepoPort
|
|
|
16
16
|
* hand-rolling a fourth divergent copy of the same fix.
|
|
17
17
|
*
|
|
18
18
|
* How it relates to the project:
|
|
19
|
-
* - `
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
19
|
+
* - `createPermissionMigrationRegistry` builds a small, in-process registry
|
|
20
|
+
* object — pure metadata, no I/O, mirrors `permissions.ts`'s
|
|
21
|
+
* `PermissionCatalog.register` overwrite semantics. A host creates ONE at its
|
|
22
|
+
* composition root and registers its own pairs on it explicitly; there is no
|
|
23
|
+
* module-scope registry, so which migrations exist never depends on which
|
|
24
|
+
* modules a process happened to import (a module-singleton filled by import
|
|
25
|
+
* side effects once ran a standalone script's boot reconciliation against an
|
|
26
|
+
* empty registry because that script never imported the registering module).
|
|
22
27
|
* - `migrateDeprecatedPermissionGrants` is the actual grant fan-out: for
|
|
23
|
-
* every
|
|
28
|
+
* every pair the caller passes in `deps.migrations` (normally its registry's
|
|
29
|
+
* `list({})`), every policy holding `from` gets every string in
|
|
24
30
|
* `to` it doesn't already hold. It is deliberately, structurally
|
|
25
31
|
* additive-only — there is no delete/update call against
|
|
26
32
|
* `policy_permissions` anywhere in this file.
|
|
@@ -42,29 +48,43 @@ export type { PermissionMigration } from '../core/builtin-permissions.js';
|
|
|
42
48
|
export interface MigrateDeprecatedPermissionGrantsDeps {
|
|
43
49
|
/** Bound to the same storage as both repository ports; required before any migration work. */
|
|
44
50
|
transactions: IdentityTransactionPort;
|
|
51
|
+
/** Every `{from, to}` pair to fan out — normally `createPermissionMigrationRegistry(...).list({})`. */
|
|
52
|
+
migrations: readonly PermissionMigration[];
|
|
45
53
|
policyPermissions: PolicyPermissionRepoPort;
|
|
46
54
|
policies: PolicyRepoPort;
|
|
47
55
|
idGen: IdGenerator;
|
|
48
56
|
workspaceId: UUID;
|
|
49
57
|
}
|
|
50
58
|
export interface MigrateDeprecatedPermissionGrantsResult {
|
|
51
|
-
/** Total new `policy_permissions` rows written across every policy and every
|
|
59
|
+
/** Total new `policy_permissions` rows written across every policy and every passed pair. */
|
|
52
60
|
migratedGrantCount: number;
|
|
53
61
|
}
|
|
62
|
+
/** A host-owned set of `{from, to, reason}` pairs; see {@link createPermissionMigrationRegistry}. */
|
|
63
|
+
export interface PermissionMigrationRegistry {
|
|
64
|
+
/**
|
|
65
|
+
* Register a `{from, to, reason}` rename/split pair for later fan-out.
|
|
66
|
+
* Idempotent: re-registering the same `from` overwrites the prior entry
|
|
67
|
+
* rather than duplicating it (matches `PermissionCatalog.register`'s
|
|
68
|
+
* existing overwrite semantics, `identity/permissions.ts`).
|
|
69
|
+
*
|
|
70
|
+
* @complexity O(1).
|
|
71
|
+
*/
|
|
72
|
+
register(migration: PermissionMigration): void;
|
|
73
|
+
/** Enumerate every registered migration pair, built-ins first, in first-registration order. */
|
|
74
|
+
list(_required: Record<string, never>): PermissionMigration[];
|
|
75
|
+
}
|
|
54
76
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* rather than duplicating it (matches `PermissionCatalog.register`'s
|
|
58
|
-
* existing overwrite semantics, `identity/permissions.ts`).
|
|
77
|
+
* Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
|
|
78
|
+
* Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
|
|
59
79
|
*
|
|
60
|
-
* @complexity O(
|
|
80
|
+
* @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
|
|
61
81
|
* @overallScore 100
|
|
62
82
|
*/
|
|
63
|
-
export declare function
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
export declare function createPermissionMigrationRegistry(_required?: Record<string, never>, optional?: {
|
|
84
|
+
migrations?: readonly PermissionMigration[];
|
|
85
|
+
}): PermissionMigrationRegistry;
|
|
66
86
|
/**
|
|
67
|
-
* For every
|
|
87
|
+
* For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
|
|
68
88
|
* holding `from`, ensure every string in `to` is also granted — adding only
|
|
69
89
|
* the rows that are missing. Never removes or mutates `from` (or any other
|
|
70
90
|
* existing row) — additive-only by construction. The entire fan-out runs in
|
|
@@ -75,7 +95,7 @@ export declare function listPermissionMigrations(_required: Record<string, never
|
|
|
75
95
|
* returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
|
|
76
96
|
* boot-safety precedent).
|
|
77
97
|
*
|
|
78
|
-
* @complexity O(p * m) where p = policies in the workspace, m =
|
|
98
|
+
* @complexity O(p * m) where p = policies in the workspace, m = passed
|
|
79
99
|
* migration pairs; both are small, bounded collections in this app's shape.
|
|
80
100
|
* @overallScore 100
|
|
81
101
|
* See docs/decisions/DR-003-additive-permission-migration.md.
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { inIdentityTransaction } from "./identity-transaction.js";
|
|
2
2
|
import { BUILTIN_PERMISSION_MIGRATIONS } from '../core/builtin-permissions.js';
|
|
3
|
-
/** Module-singleton registry — keyed by `from` so re-registration overwrites (idempotent, matches `PermissionCatalog.register`). */
|
|
4
|
-
const registry = new Map(BUILTIN_PERMISSION_MIGRATIONS.map((migration) => [migration.from, migration]));
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* rather than duplicating it (matches `PermissionCatalog.register`'s
|
|
9
|
-
* existing overwrite semantics, `identity/permissions.ts`).
|
|
4
|
+
* Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
|
|
5
|
+
* Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
|
|
10
6
|
*
|
|
11
|
-
* @complexity O(
|
|
7
|
+
* @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
|
|
12
8
|
* @overallScore 100
|
|
13
9
|
*/
|
|
14
|
-
export function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
export function createPermissionMigrationRegistry(_required = {}, optional = {}) {
|
|
11
|
+
const byFrom = new Map();
|
|
12
|
+
const register = (migration) => {
|
|
13
|
+
byFrom.set(migration.from, migration);
|
|
14
|
+
};
|
|
15
|
+
for (const migration of BUILTIN_PERMISSION_MIGRATIONS)
|
|
16
|
+
register(migration);
|
|
17
|
+
for (const migration of optional.migrations ?? [])
|
|
18
|
+
register(migration);
|
|
19
|
+
return { register, list: () => [...byFrom.values()] };
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* For every
|
|
22
|
+
* For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
|
|
23
23
|
* holding `from`, ensure every string in `to` is also granted — adding only
|
|
24
24
|
* the rows that are missing. Never removes or mutates `from` (or any other
|
|
25
25
|
* existing row) — additive-only by construction. The entire fan-out runs in
|
|
@@ -30,13 +30,13 @@ export function listPermissionMigrations(_required) {
|
|
|
30
30
|
* returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
|
|
31
31
|
* boot-safety precedent).
|
|
32
32
|
*
|
|
33
|
-
* @complexity O(p * m) where p = policies in the workspace, m =
|
|
33
|
+
* @complexity O(p * m) where p = policies in the workspace, m = passed
|
|
34
34
|
* migration pairs; both are small, bounded collections in this app's shape.
|
|
35
35
|
* @overallScore 100
|
|
36
36
|
* See docs/decisions/DR-003-additive-permission-migration.md.
|
|
37
37
|
*/
|
|
38
38
|
export async function migrateDeprecatedPermissionGrants(deps) {
|
|
39
|
-
const migrations =
|
|
39
|
+
const { migrations } = deps;
|
|
40
40
|
if (migrations.length === 0)
|
|
41
41
|
return { migratedGrantCount: 0 };
|
|
42
42
|
return inIdentityTransaction({
|
package/dist/server/seed.js
CHANGED
|
@@ -126,7 +126,12 @@ const BUILTIN_ADMIN_PERMISSIONS = [
|
|
|
126
126
|
// directly at seed — a distinct grant from settings.write, not owner-only (unlike user.manage/
|
|
127
127
|
// role.manage below).
|
|
128
128
|
"workspace.manage",
|
|
129
|
-
//
|
|
129
|
+
// Roles & Permissions: admin holds role.manage (owner decision 2026-10-05, reversing DR-001's
|
|
130
|
+
// owner-only placement for this one string). The grant service's issuer clamp still refuses any
|
|
131
|
+
// grant beyond what the caller holds (GRANT_EXCEEDS_ISSUER), so an admin cannot hand out owner.
|
|
132
|
+
// Hosts reconcile already-seeded workspaces themselves; seedIdentity never revisits them.
|
|
133
|
+
"role.manage",
|
|
134
|
+
// Owner-only per DR-001: "user.manage" is deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
|
|
130
135
|
];
|
|
131
136
|
const BUILTIN_EDITOR_PERMISSIONS = [
|
|
132
137
|
"content.read",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/user-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -86,13 +86,17 @@
|
|
|
86
86
|
"access": "public",
|
|
87
87
|
"registry": "https://registry.npmjs.org"
|
|
88
88
|
},
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"@jini-ai/core": "^0.4.2"
|
|
91
|
+
},
|
|
89
92
|
"peerDependencies": {
|
|
90
93
|
"argon2": "^0.44.0",
|
|
91
94
|
"react": "^18.3.0 || ^19.0.0",
|
|
92
95
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
93
96
|
"@jini-ai/ui": "^0.4.0",
|
|
94
97
|
"@jini-ai/admin": "^0.4.0",
|
|
95
|
-
"@jini-ai/agentic": "^0.4.0"
|
|
98
|
+
"@jini-ai/agentic": "^0.4.0",
|
|
99
|
+
"@jini-ai/ui-kit": "^0.1.1"
|
|
96
100
|
},
|
|
97
101
|
"peerDependenciesMeta": {
|
|
98
102
|
"argon2": {
|
|
@@ -112,6 +116,9 @@
|
|
|
112
116
|
},
|
|
113
117
|
"@jini-ai/agentic": {
|
|
114
118
|
"optional": true
|
|
119
|
+
},
|
|
120
|
+
"@jini-ai/ui-kit": {
|
|
121
|
+
"optional": true
|
|
115
122
|
}
|
|
116
123
|
},
|
|
117
124
|
"devDependencies": {
|
|
@@ -127,14 +134,12 @@
|
|
|
127
134
|
"@testing-library/user-event": "^14.5.0",
|
|
128
135
|
"@testing-library/jest-dom": "^6.6.3",
|
|
129
136
|
"jsdom": "^25.0.1",
|
|
130
|
-
"@jini-ai/
|
|
131
|
-
"@jini-ai/
|
|
132
|
-
"@jini-ai/
|
|
133
|
-
"@jini-ai/ui": "0.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"@jini-ai/core": "0.4.0",
|
|
137
|
-
"@jini-ai/ui-kit": "0.1.0"
|
|
137
|
+
"@jini-ai/ui": "0.4.5",
|
|
138
|
+
"@jini-ai/cms": "0.5.3",
|
|
139
|
+
"@jini-ai/core": "^0.4.2",
|
|
140
|
+
"@jini-ai/ui-kit": "^0.1.2",
|
|
141
|
+
"@jini-ai/agentic": "0.4.3",
|
|
142
|
+
"@jini-ai/admin": "0.4.3"
|
|
138
143
|
},
|
|
139
144
|
"scripts": {
|
|
140
145
|
"build": "tsc -p tsconfig.json",
|