@jini-ai/user-management 0.2.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/CHANGELOG.md +77 -0
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/dist/admin/adapters/http.d.ts +29 -0
- package/dist/admin/adapters/http.js +69 -0
- package/dist/admin/adapters/memory.d.ts +61 -0
- package/dist/admin/adapters/memory.js +217 -0
- package/dist/admin/auth.module.d.ts +30 -0
- package/dist/admin/auth.module.js +12 -0
- package/dist/admin/conformance/index.d.ts +56 -0
- package/dist/admin/conformance/index.js +156 -0
- package/dist/admin/controllers/auth.controller.d.ts +23 -0
- package/dist/admin/controllers/auth.controller.js +33 -0
- package/dist/admin/controllers/controller-store.d.ts +18 -0
- package/dist/admin/controllers/controller-store.js +23 -0
- package/dist/admin/controllers/members.controller.d.ts +33 -0
- package/dist/admin/controllers/members.controller.js +119 -0
- package/dist/admin/controllers/roles.controller.d.ts +47 -0
- package/dist/admin/controllers/roles.controller.js +282 -0
- package/dist/admin/controllers/users.controller.d.ts +53 -0
- package/dist/admin/controllers/users.controller.js +220 -0
- package/dist/admin/index.d.ts +21 -0
- package/dist/admin/index.js +15 -0
- package/dist/admin/members.module.d.ts +34 -0
- package/dist/admin/members.module.js +13 -0
- package/dist/admin/messages.en.d.ts +37 -0
- package/dist/admin/messages.en.js +21 -0
- package/dist/admin/models.d.ts +133 -0
- package/dist/admin/models.js +2 -0
- package/dist/admin/people.rules.d.ts +29 -0
- package/dist/admin/people.rules.js +51 -0
- package/dist/admin/ports.d.ts +187 -0
- package/dist/admin/ports.js +17 -0
- package/dist/admin/react/components/DestructiveDialog.d.ts +5 -0
- package/dist/admin/react/components/DestructiveDialog.js +6 -0
- package/dist/admin/react/components/MemberRow.d.ts +5 -0
- package/dist/admin/react/components/MemberRow.js +8 -0
- package/dist/admin/react/components/MembersConfirmation.d.ts +2 -0
- package/dist/admin/react/components/MembersConfirmation.js +8 -0
- package/dist/admin/react/components/PermissionPanel.d.ts +5 -0
- package/dist/admin/react/components/PermissionPanel.js +9 -0
- package/dist/admin/react/components/PolicyRow.d.ts +5 -0
- package/dist/admin/react/components/PolicyRow.js +11 -0
- package/dist/admin/react/components/RoleRow.d.ts +5 -0
- package/dist/admin/react/components/RoleRow.js +10 -0
- package/dist/admin/react/components/UserRow.d.ts +5 -0
- package/dist/admin/react/components/UserRow.js +8 -0
- package/dist/admin/react/components/UsersConfirmation.d.ts +3 -0
- package/dist/admin/react/components/UsersConfirmation.js +9 -0
- package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +39 -0
- package/dist/admin/react/hooks/AuthBinding.hooks.js +21 -0
- package/dist/admin/react/hooks/LoginPage.hooks.d.ts +54 -0
- package/dist/admin/react/hooks/LoginPage.hooks.js +23 -0
- package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +35 -0
- package/dist/admin/react/hooks/MembersBinding.hooks.js +21 -0
- package/dist/admin/react/hooks/MembersPage.hooks.d.ts +99 -0
- package/dist/admin/react/hooks/MembersPage.hooks.js +38 -0
- package/dist/admin/react/hooks/PeopleController.hooks.d.ts +18 -0
- package/dist/admin/react/hooks/PeopleController.hooks.js +20 -0
- package/dist/admin/react/hooks/PermissionPanel.hooks.d.ts +30 -0
- package/dist/admin/react/hooks/PermissionPanel.hooks.js +15 -0
- package/dist/admin/react/hooks/PoliciesTab.hooks.d.ts +47 -0
- package/dist/admin/react/hooks/PoliciesTab.hooks.js +26 -0
- package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +39 -0
- package/dist/admin/react/hooks/RolesBinding.hooks.js +23 -0
- package/dist/admin/react/hooks/RolesController.hooks.d.ts +43 -0
- package/dist/admin/react/hooks/RolesController.hooks.js +25 -0
- package/dist/admin/react/hooks/RolesPage.hooks.d.ts +105 -0
- package/dist/admin/react/hooks/RolesPage.hooks.js +33 -0
- package/dist/admin/react/hooks/RolesTab.hooks.d.ts +35 -0
- package/dist/admin/react/hooks/RolesTab.hooks.js +19 -0
- package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +36 -0
- package/dist/admin/react/hooks/UsersBinding.hooks.js +21 -0
- package/dist/admin/react/hooks/UsersPage.hooks.d.ts +106 -0
- package/dist/admin/react/hooks/UsersPage.hooks.js +22 -0
- package/dist/admin/react/hooks/UsersTab.hooks.d.ts +101 -0
- package/dist/admin/react/hooks/UsersTab.hooks.js +53 -0
- package/dist/admin/react/index.d.ts +203 -0
- package/dist/admin/react/index.js +32 -0
- package/dist/admin/react/pages/LoginPage.d.ts +3 -0
- package/dist/admin/react/pages/LoginPage.js +13 -0
- package/dist/admin/react/pages/MembersPage.d.ts +3 -0
- package/dist/admin/react/pages/MembersPage.js +19 -0
- package/dist/admin/react/pages/RolesPage.d.ts +3 -0
- package/dist/admin/react/pages/RolesPage.js +19 -0
- package/dist/admin/react/pages/UsersPage.d.ts +3 -0
- package/dist/admin/react/pages/UsersPage.js +19 -0
- package/dist/admin/react/tabs/AuthTab.d.ts +4 -0
- package/dist/admin/react/tabs/AuthTab.js +8 -0
- package/dist/admin/react/tabs/MembersTab.d.ts +4 -0
- package/dist/admin/react/tabs/MembersTab.js +9 -0
- package/dist/admin/react/tabs/PoliciesTab.d.ts +4 -0
- package/dist/admin/react/tabs/PoliciesTab.js +9 -0
- package/dist/admin/react/tabs/RolesTab.d.ts +4 -0
- package/dist/admin/react/tabs/RolesTab.js +9 -0
- package/dist/admin/react/tabs/UsersTab.d.ts +4 -0
- package/dist/admin/react/tabs/UsersTab.js +9 -0
- package/dist/admin/roles.module.d.ts +48 -0
- package/dist/admin/roles.module.js +15 -0
- package/dist/admin/rules.d.ts +32 -0
- package/dist/admin/rules.js +32 -0
- package/dist/admin/users.module.d.ts +39 -0
- package/dist/admin/users.module.js +14 -0
- package/dist/core/builtin-permissions.d.ts +10 -0
- package/dist/core/builtin-permissions.js +402 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +9 -0
- package/dist/core/permissions.d.ts +40 -0
- package/dist/core/permissions.js +52 -0
- package/dist/core/ports.d.ts +237 -0
- package/dist/core/ports.js +2 -0
- package/dist/core/runtime-ports.d.ts +8 -0
- package/dist/core/runtime-ports.js +2 -0
- package/dist/core/types.d.ts +224 -0
- package/dist/core/types.js +93 -0
- package/dist/react/auth/Login.d.ts +9 -0
- package/dist/react/auth/Login.js +8 -0
- package/dist/react/auth/hooks/use-login.hooks.d.ts +22 -0
- package/dist/react/auth/hooks/use-login.hooks.js +24 -0
- package/dist/react/errors.d.ts +10 -0
- package/dist/react/errors.js +13 -0
- package/dist/react/index.d.ts +18 -0
- package/dist/react/index.js +9 -0
- package/dist/react/members/Members.d.ts +6 -0
- package/dist/react/members/Members.js +38 -0
- package/dist/react/members/hooks/use-members.hooks.d.ts +32 -0
- package/dist/react/members/hooks/use-members.hooks.js +120 -0
- package/dist/react/members/rules.d.ts +26 -0
- package/dist/react/members/rules.js +35 -0
- package/dist/react/models.d.ts +50 -0
- package/dist/react/models.js +2 -0
- package/dist/react/ports.d.ts +157 -0
- package/dist/react/ports.js +2 -0
- package/dist/react/roles/Roles.d.ts +100 -0
- package/dist/react/roles/Roles.hooks.d.ts +17 -0
- package/dist/react/roles/Roles.hooks.js +40 -0
- package/dist/react/roles/Roles.js +150 -0
- package/dist/react/roles/hooks/use-roles.hooks.d.ts +80 -0
- package/dist/react/roles/hooks/use-roles.hooks.js +303 -0
- package/dist/react/roles/rules.d.ts +35 -0
- package/dist/react/roles/rules.js +31 -0
- package/dist/react/roles/text.d.ts +27 -0
- package/dist/react/roles/text.js +13 -0
- package/dist/react/testing.d.ts +51 -0
- package/dist/react/testing.js +275 -0
- package/dist/react/users/Users.d.ts +72 -0
- package/dist/react/users/Users.js +129 -0
- package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +16 -0
- package/dist/react/users/hooks/use-reset-password-fields.hooks.js +21 -0
- package/dist/react/users/hooks/use-users.hooks.d.ts +77 -0
- package/dist/react/users/hooks/use-users.hooks.js +286 -0
- package/dist/react/users/rules.d.ts +33 -0
- package/dist/react/users/rules.js +61 -0
- package/dist/server/admin-crud-service.d.ts +295 -0
- package/dist/server/admin-crud-service.js +561 -0
- package/dist/server/agent-tool-input.d.ts +58 -0
- package/dist/server/agent-tool-input.js +158 -0
- package/dist/server/agent-tool-registrations.d.ts +51 -0
- package/dist/server/agent-tool-registrations.js +417 -0
- package/dist/server/agent-tools.d.ts +88 -0
- package/dist/server/agent-tools.js +229 -0
- package/dist/server/auth-service.d.ts +107 -0
- package/dist/server/auth-service.js +170 -0
- package/dist/server/authorize.d.ts +92 -0
- package/dist/server/authorize.js +96 -0
- package/dist/server/grant-service.d.ts +219 -0
- package/dist/server/grant-service.js +419 -0
- package/dist/server/hasher.d.ts +64 -0
- package/dist/server/hasher.js +53 -0
- package/dist/server/identity-transaction.d.ts +9 -0
- package/dist/server/identity-transaction.js +8 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.js +20 -0
- package/dist/server/messages.d.ts +7 -0
- package/dist/server/messages.js +5 -0
- package/dist/server/password-policy.d.ts +59 -0
- package/dist/server/password-policy.js +66 -0
- package/dist/server/permission-migrations.d.ts +84 -0
- package/dist/server/permission-migrations.js +77 -0
- package/dist/server/repo.memory-transactions.d.ts +16 -0
- package/dist/server/repo.memory-transactions.js +79 -0
- package/dist/server/repo.memory.d.ts +186 -0
- package/dist/server/repo.memory.js +230 -0
- package/dist/server/seed.d.ts +56 -0
- package/dist/server/seed.js +435 -0
- package/dist/server/session-tokens.d.ts +10 -0
- package/dist/server/session-tokens.js +12 -0
- package/dist/server/username.d.ts +14 -0
- package/dist/server/username.js +14 -0
- package/package.json +144 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { inIdentityTransaction } from "./identity-transaction.js";
|
|
3
|
+
import { authorize, resolveEffectivePermissions } from "./authorize.js";
|
|
4
|
+
import { GrantExceedsIssuerError, IdentityConflictError, IdentityForbiddenError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, } from "../core/types.js";
|
|
5
|
+
import { normalizeUsername } from "./username.js";
|
|
6
|
+
import { validatePasswordPolicy } from "./password-policy.js";
|
|
7
|
+
/**
|
|
8
|
+
* @file Human grant-writing transitions (state.spec.md §3): `CREATE_USER`,
|
|
9
|
+
* `CREATE_ROLE`, `CREATE_POLICY`, `ASSIGN_ROLE`, `ATTACH_POLICY`.
|
|
10
|
+
*
|
|
11
|
+
* Purpose:
|
|
12
|
+
* The only usable identity principal before this file was the seeded owner —
|
|
13
|
+
* there was no API path to create a second user or grant/change permissions.
|
|
14
|
+
* These five functions are that path, following `auth-service.ts`'s exact
|
|
15
|
+
* shape (`{ deps: AuthServiceDeps, input: {...} }`, one exported async
|
|
16
|
+
* function per transition, plain `Error` subclasses for control flow).
|
|
17
|
+
*
|
|
18
|
+
* Out of scope (deferred, see the dispatch directive): `ISSUE_API_KEY`,
|
|
19
|
+
* `CREATE_PRINCIPAL` (api_key/agent principal creation), agent/api_key
|
|
20
|
+
* delegation, `WRITE_POLICY_PERMISSION`, `DISABLE_PRINCIPAL`.
|
|
21
|
+
*
|
|
22
|
+
* Architectural role:
|
|
23
|
+
* Ordinary core functions, not ports (mirrors `auth-service.ts`'s reasoning —
|
|
24
|
+
* grant-writing logic has one implementation). Each function calls
|
|
25
|
+
* `authorize`/`resolveEffectivePermissions` from `authorize.ts` itself
|
|
26
|
+
* (rather than pushing the gate into the route layer, the way
|
|
27
|
+
* `members/disable.ts`'s route does) so the caller-permission gate and the
|
|
28
|
+
* clamp are exercised directly by unit tests, no HTTP harness needed.
|
|
29
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Assemble the `AuthorizeDeps` bag `authorize()`/`resolveEffectivePermissions()` expect from the
|
|
33
|
+
* flat `IdentityRepos` bag. Exported so `admin-crud-service.ts`'s new transitions
|
|
34
|
+
* (`ENABLE_PRINCIPAL`/`UPDATE_USER`/`RESET_USER_PASSWORD`/`UPDATE_ROLE`/`UPDATE_POLICY`/
|
|
35
|
+
* `DELETE_ROLE`/`DELETE_POLICY`/`WRITE_POLICY_PERMISSION`/`DISABLE_PRINCIPAL`) reuse the identical
|
|
36
|
+
* gate/clamp logic rather than duplicating it — same file-split reasoning `auth-service.ts` and
|
|
37
|
+
* `grant-service.ts` already use (one shared helper set, two transition files).
|
|
38
|
+
*/
|
|
39
|
+
export function authorizeDepsFrom(repos) {
|
|
40
|
+
return {
|
|
41
|
+
principals: repos.principals,
|
|
42
|
+
principalRoles: repos.principalRoles,
|
|
43
|
+
rolePolicies: repos.rolePolicies,
|
|
44
|
+
principalPolicies: repos.principalPolicies,
|
|
45
|
+
policyPermissions: repos.policyPermissions,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** True iff `principalId` holds the owner wildcard `*` (unconstrained) in its effective set. */
|
|
49
|
+
export async function principalHoldsOwnerWildcard(required) {
|
|
50
|
+
const { deps, workspaceId, principalId } = required;
|
|
51
|
+
const effectiveRows = await resolveEffectivePermissions({
|
|
52
|
+
deps: authorizeDepsFrom(deps.repos),
|
|
53
|
+
principalId,
|
|
54
|
+
workspaceId,
|
|
55
|
+
});
|
|
56
|
+
return effectiveRows.some((row) => row.permission === "*" && row.resourceType == null && row.constraintJson == null);
|
|
57
|
+
}
|
|
58
|
+
/** Owner-account writes reuse the same unconstrained wildcard test as the active-owner floor. */
|
|
59
|
+
export async function assertOwnerTargetMayBeModified(required) {
|
|
60
|
+
const { deps, workspaceId, callerPrincipalId, principalId } = required;
|
|
61
|
+
if (!(await principalHoldsOwnerWildcard({ deps, workspaceId, principalId })))
|
|
62
|
+
return;
|
|
63
|
+
// Delegated management must never become authority over an owner's account or credentials.
|
|
64
|
+
if (!(await principalHoldsOwnerWildcard({ deps, workspaceId, principalId: callerPrincipalId }))) {
|
|
65
|
+
throw new OwnerRequiredError({ message: "only an owner can modify an owner principal" });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Fail-closed caller-permission gate shared by every transition in this file.
|
|
70
|
+
* Throws `IdentityForbiddenError` (403 `FORBIDDEN`) unless the caller holds
|
|
71
|
+
* at least one of `permissions` (an OR gate where the transition permits it;
|
|
72
|
+
* operator creation requires `user.manage`, while self-profile edits retain their existing OR).
|
|
73
|
+
*
|
|
74
|
+
* @complexity O(p) `authorize()` calls, p = `permissions.length` (1 or 2 in
|
|
75
|
+
* every caller this pass) — each itself O(resolveEffectivePermissions), see
|
|
76
|
+
* that function's doc.
|
|
77
|
+
* @overallScore 100
|
|
78
|
+
*/
|
|
79
|
+
export async function assertCallerHasAnyPermission(required) {
|
|
80
|
+
const { deps, workspaceId, callerPrincipalId, permissions } = required;
|
|
81
|
+
let lastReason = "no_grant";
|
|
82
|
+
for (const permission of permissions) {
|
|
83
|
+
const result = await authorize({
|
|
84
|
+
deps: authorizeDepsFrom(deps.repos),
|
|
85
|
+
principalId: callerPrincipalId,
|
|
86
|
+
permission,
|
|
87
|
+
context: { workspaceId },
|
|
88
|
+
});
|
|
89
|
+
if (result.allowed)
|
|
90
|
+
return;
|
|
91
|
+
lastReason = result.reason;
|
|
92
|
+
}
|
|
93
|
+
throw new IdentityForbiddenError({ message: `principal '${callerPrincipalId}' is not authorized for any of [${permissions.join(", ")}]`, permission: permissions.join("|"), reason: lastReason });
|
|
94
|
+
}
|
|
95
|
+
/** True iff `effectiveRows` contains an unconstrained hold of `permission` — an exact match with
|
|
96
|
+
* `resourceType`/`constraintJson` both null, or the owner wildcard `*` (which counts as an
|
|
97
|
+
* unconstrained hold of every permission).
|
|
98
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
99
|
+
*/
|
|
100
|
+
function holdsUnconstrained(effectiveRows, permission) {
|
|
101
|
+
const hasWildcard = effectiveRows.some((row) => row.permission === "*" && row.resourceType == null && row.constraintJson == null);
|
|
102
|
+
if (hasWildcard)
|
|
103
|
+
return true;
|
|
104
|
+
return effectiveRows.some((row) => row.permission === permission && row.resourceType == null && row.constraintJson == null);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The grant-authority clamp (feature.spec.md, state.spec §3
|
|
108
|
+
* `ASSIGN_ROLE`/`ATTACH_POLICY` rows): every permission in `conferredPermissions`
|
|
109
|
+
* must be a matching effective row the caller holds unconstrained
|
|
110
|
+
* (`resourceType`/`constraintJson` both null; owner's `*` qualifies for all).
|
|
111
|
+
* A permission held only conditionally (resource-scoped or constraint-bound)
|
|
112
|
+
* cannot be delegated. Throws `GrantExceedsIssuerError` (403
|
|
113
|
+
* `GRANT_EXCEEDS_ISSUER`) naming every offending permission, and writes no
|
|
114
|
+
* grant row (caller does the write only after this resolves).
|
|
115
|
+
*
|
|
116
|
+
* @complexity O(resolveEffectivePermissions) + O(k) local filtering, k =
|
|
117
|
+
* `conferredPermissions.length` (bounded by one role's/policy's permission
|
|
118
|
+
* rows — a handful, per `identity/INFO.md`'s scale assumptions).
|
|
119
|
+
* @overallScore 100
|
|
120
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
121
|
+
*/
|
|
122
|
+
export async function assertGrantClamp(required) {
|
|
123
|
+
const { deps, workspaceId, callerPrincipalId, conferredPermissions } = required;
|
|
124
|
+
const distinctPermissions = [...new Set(conferredPermissions)];
|
|
125
|
+
if (distinctPermissions.length === 0)
|
|
126
|
+
return;
|
|
127
|
+
const effectiveRows = await resolveEffectivePermissions({
|
|
128
|
+
deps: authorizeDepsFrom(deps.repos),
|
|
129
|
+
principalId: callerPrincipalId,
|
|
130
|
+
workspaceId,
|
|
131
|
+
});
|
|
132
|
+
const offendingPermissions = distinctPermissions.filter((permission) => !holdsUnconstrained(effectiveRows, permission));
|
|
133
|
+
if (offendingPermissions.length > 0) {
|
|
134
|
+
throw new GrantExceedsIssuerError({ message: `principal '${callerPrincipalId}' cannot grant permission(s) it does not hold unconstrained: ${offendingPermissions.join(", ")}`, offendingPermissions: offendingPermissions });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `ASSIGN_ROLE`/`ATTACH_POLICY`'s shared human-target-only rule :
|
|
139
|
+
* machine (`api_key`/`system`) principal authority is set solely at
|
|
140
|
+
* issuance, never mutated by a later human-grant transition. Throws
|
|
141
|
+
* `IdentityValidationError` (400 `VALIDATION_ERROR`) for a non-`user` target;
|
|
142
|
+
* a missing target throws `IdentityNotFoundError`. This check runs *before* the clamp ( note:
|
|
143
|
+
* "does not reach this clamp").
|
|
144
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
145
|
+
*/
|
|
146
|
+
async function findAssignableTargetOrThrow(required) {
|
|
147
|
+
const { deps, workspaceId, principalId, transitionName } = required;
|
|
148
|
+
const target = await deps.repos.principals.findById({ workspaceId, id: principalId });
|
|
149
|
+
if (!target) {
|
|
150
|
+
throw new IdentityNotFoundError({ message: `principal '${principalId}' was not found` });
|
|
151
|
+
}
|
|
152
|
+
if (target.kind !== "user") {
|
|
153
|
+
throw new IdentityValidationError({ message: `${transitionName} target must be a human (kind='user') principal, got kind='${target.kind}'` });
|
|
154
|
+
}
|
|
155
|
+
return target;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `CREATE_USER` (state.spec §3). Atomically mints a NEW
|
|
159
|
+
* `kind='user'` principal and its `users` row — the `principalId` is always
|
|
160
|
+
* generated here, never caller-supplied, so this transition can never attach
|
|
161
|
+
* a credential to a pre-existing principal (the privilege-escalation
|
|
162
|
+
* class: an earlier draft let an admin bind a password to the seeded owner
|
|
163
|
+
* principal and log in as owner). Gated by `user.manage`; front-end
|
|
164
|
+
* `member.manage` never authorizes minting an operator credential.
|
|
165
|
+
*
|
|
166
|
+
* "Atomically" here means structurally, not via a DB transaction — the
|
|
167
|
+
* in-memory adapters have no cross-repo transaction (same disclosed gap as
|
|
168
|
+
* `seed.ts`/`executeCommand`'s compensating-rollback path). The invariant
|
|
169
|
+
* that actually matters (no credential ever attaches to an existing
|
|
170
|
+
* principal) holds regardless, because `principalId` is generated inline and
|
|
171
|
+
* the password is hashed before either row is written, so a hashing failure
|
|
172
|
+
* leaves no orphan principal. A future SQLite adapter wraps both writes in
|
|
173
|
+
* one real transaction (port/adapter rule-of-two) without changing this contract.
|
|
174
|
+
*
|
|
175
|
+
* @complexity O(1) — one permission check, one username lookup, one hash,
|
|
176
|
+
* two saves.
|
|
177
|
+
* @overallScore 100
|
|
178
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
179
|
+
*/
|
|
180
|
+
export async function createUser(required, optional = {}) {
|
|
181
|
+
const { deps } = required;
|
|
182
|
+
const input = { ...required.input, ...optional };
|
|
183
|
+
await assertCallerHasAnyPermission({
|
|
184
|
+
deps,
|
|
185
|
+
workspaceId: input.workspaceId,
|
|
186
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
187
|
+
// Member administration must not mint principals that can log into the operator plane.
|
|
188
|
+
permissions: ["user.manage"],
|
|
189
|
+
});
|
|
190
|
+
const username = normalizeUsername({ raw: input.username ?? "" });
|
|
191
|
+
if (!username || !input.password) {
|
|
192
|
+
throw new IdentityValidationError({ message: "username and password are required" });
|
|
193
|
+
}
|
|
194
|
+
// Presence-and-upper-bound-only policy (MSG-04, no minimum length) — never reaches seed.ts's
|
|
195
|
+
// owner password, see password-policy.ts's own header for why that separation is deliberate.
|
|
196
|
+
const passwordError = validatePasswordPolicy({ password: input.password });
|
|
197
|
+
if (passwordError) {
|
|
198
|
+
throw new IdentityValidationError({ message: passwordError });
|
|
199
|
+
}
|
|
200
|
+
const existingUser = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
|
|
201
|
+
if (existingUser) {
|
|
202
|
+
throw new IdentityConflictError({ message: `username '${username}' is already in use` });
|
|
203
|
+
}
|
|
204
|
+
const passwordHash = await deps.hasher.hash({ password: input.password });
|
|
205
|
+
const nowIso = kernelNowIso({ clock: deps.clock });
|
|
206
|
+
const principalId = deps.idGen.newId();
|
|
207
|
+
const principal = {
|
|
208
|
+
id: principalId,
|
|
209
|
+
workspaceId: input.workspaceId,
|
|
210
|
+
kind: "user",
|
|
211
|
+
displayName: username,
|
|
212
|
+
status: "active",
|
|
213
|
+
createdAt: nowIso,
|
|
214
|
+
};
|
|
215
|
+
const user = {
|
|
216
|
+
principalId,
|
|
217
|
+
workspaceId: input.workspaceId,
|
|
218
|
+
username,
|
|
219
|
+
email: input.email,
|
|
220
|
+
passwordHash,
|
|
221
|
+
};
|
|
222
|
+
await deps.repos.principals.save(principal);
|
|
223
|
+
await deps.repos.users.save(user);
|
|
224
|
+
return { principal, user };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `CREATE_ROLE` (state.spec §3). Gated by `role.manage`. Always mints
|
|
228
|
+
* `isBuiltin=false` — built-ins are SEED-only and immutable.
|
|
229
|
+
*
|
|
230
|
+
* Deviation from the state.spec §3 table (disclosed): that row lists
|
|
231
|
+
* `name`, `description?` jointly for `CREATE_ROLE`/`CREATE_POLICY`, but
|
|
232
|
+
* `RoleRecord` (types.ts, frozen before this task) has no `description`
|
|
233
|
+
* field — only `PolicyRecord` does. `createRole` therefore takes `name`
|
|
234
|
+
* only; `createPolicy` below takes both.
|
|
235
|
+
*
|
|
236
|
+
* @complexity O(1) — one permission check, one save.
|
|
237
|
+
* @overallScore 100
|
|
238
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
239
|
+
*/
|
|
240
|
+
export async function createRole(required) {
|
|
241
|
+
const { deps, input } = required;
|
|
242
|
+
await assertCallerHasAnyPermission({
|
|
243
|
+
deps,
|
|
244
|
+
workspaceId: input.workspaceId,
|
|
245
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
246
|
+
permissions: ["role.manage"],
|
|
247
|
+
});
|
|
248
|
+
const name = (input.name ?? "").trim();
|
|
249
|
+
if (!name) {
|
|
250
|
+
throw new IdentityValidationError({ message: "role name is required" });
|
|
251
|
+
}
|
|
252
|
+
const role = {
|
|
253
|
+
id: deps.idGen.newId(),
|
|
254
|
+
workspaceId: input.workspaceId,
|
|
255
|
+
name,
|
|
256
|
+
isBuiltin: false,
|
|
257
|
+
};
|
|
258
|
+
await deps.repos.roles.save(role);
|
|
259
|
+
return { role };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* `CREATE_POLICY` (state.spec §3). Gated by `role.manage`. Always mints
|
|
263
|
+
* `isBuiltin=false` and `isFrozen=false` — only the (out-of-scope this pass)
|
|
264
|
+
* `ISSUE_API_KEY` transition ever mints `isFrozen=true` policies.
|
|
265
|
+
*
|
|
266
|
+
* @complexity O(1) — one permission check, one save.
|
|
267
|
+
* @overallScore 100
|
|
268
|
+
*/
|
|
269
|
+
export async function createPolicy(required, optional = {}) {
|
|
270
|
+
const { deps } = required;
|
|
271
|
+
const input = { ...required.input, ...optional };
|
|
272
|
+
await assertCallerHasAnyPermission({
|
|
273
|
+
deps,
|
|
274
|
+
workspaceId: input.workspaceId,
|
|
275
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
276
|
+
permissions: ["role.manage"],
|
|
277
|
+
});
|
|
278
|
+
const name = (input.name ?? "").trim();
|
|
279
|
+
if (!name) {
|
|
280
|
+
throw new IdentityValidationError({ message: "policy name is required" });
|
|
281
|
+
}
|
|
282
|
+
const policy = {
|
|
283
|
+
id: deps.idGen.newId(),
|
|
284
|
+
workspaceId: input.workspaceId,
|
|
285
|
+
name,
|
|
286
|
+
description: input.description,
|
|
287
|
+
isBuiltin: false,
|
|
288
|
+
isFrozen: false,
|
|
289
|
+
};
|
|
290
|
+
await deps.repos.policies.save(policy);
|
|
291
|
+
return { policy };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `ASSIGN_ROLE` (state.spec §3). Gated by `role.manage`;
|
|
295
|
+
* target must be a `kind='user'` principal; the clamp must
|
|
296
|
+
* pass over every permission carried by the role's policies — so
|
|
297
|
+
* assigning the built-in `owner` role requires the caller to already hold
|
|
298
|
+
* the wildcard `*` (owner-only). No row is written on any rejection.
|
|
299
|
+
*
|
|
300
|
+
* @complexity O(resolveEffectivePermissions) + O(role's rolePolicy count)
|
|
301
|
+
* round trips to gather conferred permissions — both bounded by the small,
|
|
302
|
+
* hand-authored grant graph this system assumes (see `identity/INFO.md`).
|
|
303
|
+
* @overallScore 100
|
|
304
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
305
|
+
*/
|
|
306
|
+
export async function assignRole(required) {
|
|
307
|
+
const { deps, input } = required;
|
|
308
|
+
return inIdentityTransaction({
|
|
309
|
+
transactions: deps.repos.transactions,
|
|
310
|
+
workspaceId: input.workspaceId,
|
|
311
|
+
execute: async () => {
|
|
312
|
+
await assertCallerHasAnyPermission({
|
|
313
|
+
deps,
|
|
314
|
+
workspaceId: input.workspaceId,
|
|
315
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
316
|
+
permissions: ["role.manage"],
|
|
317
|
+
});
|
|
318
|
+
await findAssignableTargetOrThrow({
|
|
319
|
+
deps,
|
|
320
|
+
workspaceId: input.workspaceId,
|
|
321
|
+
principalId: input.principalId,
|
|
322
|
+
transitionName: "ASSIGN_ROLE",
|
|
323
|
+
});
|
|
324
|
+
await assertOwnerTargetMayBeModified({
|
|
325
|
+
deps,
|
|
326
|
+
workspaceId: input.workspaceId,
|
|
327
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
328
|
+
principalId: input.principalId,
|
|
329
|
+
});
|
|
330
|
+
const role = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
|
|
331
|
+
if (!role) {
|
|
332
|
+
throw new IdentityNotFoundError({ message: `role '${input.roleId}' was not found` });
|
|
333
|
+
}
|
|
334
|
+
const rolePolicyLinks = await deps.repos.rolePolicies.listByRoleId({
|
|
335
|
+
workspaceId: input.workspaceId,
|
|
336
|
+
roleId: input.roleId,
|
|
337
|
+
});
|
|
338
|
+
const policyPermissionGroups = await Promise.all(rolePolicyLinks.map((link) => deps.repos.policyPermissions.listByPolicyId({ workspaceId: input.workspaceId, policyId: link.policyId })));
|
|
339
|
+
const conferredPermissions = policyPermissionGroups.flat().map((row) => row.permission);
|
|
340
|
+
await assertGrantClamp({
|
|
341
|
+
deps,
|
|
342
|
+
workspaceId: input.workspaceId,
|
|
343
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
344
|
+
conferredPermissions,
|
|
345
|
+
});
|
|
346
|
+
const assignment = {
|
|
347
|
+
id: deps.idGen.newId(),
|
|
348
|
+
workspaceId: input.workspaceId,
|
|
349
|
+
principalId: input.principalId,
|
|
350
|
+
roleId: input.roleId,
|
|
351
|
+
};
|
|
352
|
+
await deps.repos.principalRoles.save(assignment);
|
|
353
|
+
return { assignment };
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* `ATTACH_POLICY` (state.spec §3). Gated by
|
|
359
|
+
* `role.manage`; target must be a `kind='user'` principal; the
|
|
360
|
+
* clamp must pass over every permission the policy carries —
|
|
361
|
+
* attaching the built-in `owner` policy requires the caller to already hold
|
|
362
|
+
* `*` (owner-only). No row is written on any rejection.
|
|
363
|
+
*
|
|
364
|
+
* @complexity O(resolveEffectivePermissions) + O(policy's permission row
|
|
365
|
+
* count) — both bounded, see `assignRole`'s doc.
|
|
366
|
+
* @overallScore 100
|
|
367
|
+
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
368
|
+
*/
|
|
369
|
+
export async function attachPolicy(required) {
|
|
370
|
+
const { deps, input } = required;
|
|
371
|
+
return inIdentityTransaction({
|
|
372
|
+
transactions: deps.repos.transactions,
|
|
373
|
+
workspaceId: input.workspaceId,
|
|
374
|
+
execute: async () => {
|
|
375
|
+
await assertCallerHasAnyPermission({
|
|
376
|
+
deps,
|
|
377
|
+
workspaceId: input.workspaceId,
|
|
378
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
379
|
+
permissions: ["role.manage"],
|
|
380
|
+
});
|
|
381
|
+
await findAssignableTargetOrThrow({
|
|
382
|
+
deps,
|
|
383
|
+
workspaceId: input.workspaceId,
|
|
384
|
+
principalId: input.principalId,
|
|
385
|
+
transitionName: "ATTACH_POLICY",
|
|
386
|
+
});
|
|
387
|
+
await assertOwnerTargetMayBeModified({
|
|
388
|
+
deps,
|
|
389
|
+
workspaceId: input.workspaceId,
|
|
390
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
391
|
+
principalId: input.principalId,
|
|
392
|
+
});
|
|
393
|
+
const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
|
|
394
|
+
if (!policy) {
|
|
395
|
+
throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
|
|
396
|
+
}
|
|
397
|
+
const permissionRows = await deps.repos.policyPermissions.listByPolicyId({
|
|
398
|
+
workspaceId: input.workspaceId,
|
|
399
|
+
policyId: input.policyId,
|
|
400
|
+
});
|
|
401
|
+
const conferredPermissions = permissionRows.map((row) => row.permission);
|
|
402
|
+
await assertGrantClamp({
|
|
403
|
+
deps,
|
|
404
|
+
workspaceId: input.workspaceId,
|
|
405
|
+
callerPrincipalId: input.callerPrincipalId,
|
|
406
|
+
conferredPermissions,
|
|
407
|
+
});
|
|
408
|
+
const attachment = {
|
|
409
|
+
id: deps.idGen.newId(),
|
|
410
|
+
workspaceId: input.workspaceId,
|
|
411
|
+
principalId: input.principalId,
|
|
412
|
+
policyId: input.policyId,
|
|
413
|
+
};
|
|
414
|
+
await deps.repos.principalPolicies.save(attachment);
|
|
415
|
+
return { attachment };
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
//# sourceMappingURL=grant-service.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PasswordHasherPort } from "../core/ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file argon2id password hashing.
|
|
4
|
+
*
|
|
5
|
+
* Purpose:
|
|
6
|
+
* The one real `PasswordHasherPort` adapter this pass — native argon2
|
|
7
|
+
* bindings, verified installable/buildable in this environment (see the
|
|
8
|
+
* Programmer handoff). Raw passwords are never stored or logged; only the
|
|
9
|
+
* hash produced here is persisted (`users.passwordHash`).
|
|
10
|
+
*
|
|
11
|
+
* Architectural role:
|
|
12
|
+
* Rule-of-two candidate per feature.spec's Dependencies table — only one
|
|
13
|
+
* adapter exists this pass, matching the spec's own "candidate" framing.
|
|
14
|
+
* See docs/decisions/DR-001-identity-and-session-boundary.md.
|
|
15
|
+
*/
|
|
16
|
+
/** Tunable cost parameters, defaulted to OWASP's current argon2id minimums. */
|
|
17
|
+
export interface Argon2PasswordHasherOptions {
|
|
18
|
+
/** KiB. OWASP minimum recommendation is 19456 (~19 MiB). */
|
|
19
|
+
memoryCost?: number;
|
|
20
|
+
/** Iteration count. OWASP minimum recommendation is 2 at this memory cost. */
|
|
21
|
+
timeCost?: number;
|
|
22
|
+
/** Degree of parallelism. */
|
|
23
|
+
parallelism?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Binding seam; hosts may inject a native adapter or another implementation. */
|
|
26
|
+
export interface Argon2BindingPort {
|
|
27
|
+
argon2id: number;
|
|
28
|
+
hash(required: {
|
|
29
|
+
password: string;
|
|
30
|
+
}, optional?: Argon2PasswordHasherOptions & {
|
|
31
|
+
type?: number;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
verify(required: {
|
|
34
|
+
hash: string;
|
|
35
|
+
password: string;
|
|
36
|
+
}): Promise<boolean>;
|
|
37
|
+
}
|
|
38
|
+
export interface Argon2PasswordHasherDependencies {
|
|
39
|
+
loadBinding: (required: Record<string, never>) => Argon2BindingPort;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve the optional native peer only when this adapter is used. */
|
|
42
|
+
export declare function loadArgon2Binding(_required: Record<string, never>): Argon2BindingPort;
|
|
43
|
+
/**
|
|
44
|
+
* argon2id-backed `PasswordHasherPort`. `verify` never throws on a malformed
|
|
45
|
+
* or foreign hash — it resolves `false`, so a corrupted `passwordHash` row
|
|
46
|
+
* degrades to "wrong password," never to a 500 or an authentication bypass.
|
|
47
|
+
*
|
|
48
|
+
* @complexity O(1) calls into the native binding; cost is tuned by
|
|
49
|
+
* `memoryCost`/`timeCost`/`parallelism`, not by input size.
|
|
50
|
+
* @overallScore 100
|
|
51
|
+
*/
|
|
52
|
+
export declare class Argon2PasswordHasher implements PasswordHasherPort {
|
|
53
|
+
private readonly options;
|
|
54
|
+
private readonly loadBinding;
|
|
55
|
+
constructor({ loadBinding }: Argon2PasswordHasherDependencies, options?: Argon2PasswordHasherOptions);
|
|
56
|
+
hash({ password }: {
|
|
57
|
+
password: string;
|
|
58
|
+
}): Promise<string>;
|
|
59
|
+
verify({ hash, password }: {
|
|
60
|
+
hash: string;
|
|
61
|
+
password: string;
|
|
62
|
+
}): Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=hasher.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const requirePeer = createRequire(import.meta.url);
|
|
3
|
+
/** Resolve the optional native peer only when this adapter is used. */
|
|
4
|
+
export function loadArgon2Binding(_required) {
|
|
5
|
+
const native = requirePeer("argon2");
|
|
6
|
+
return {
|
|
7
|
+
argon2id: native.argon2id,
|
|
8
|
+
hash: ({ password }, optional = {}) => native.hash(password, { ...DEFAULT_OPTIONS, type: native.argon2id, ...optional }),
|
|
9
|
+
verify: ({ hash, password }) => native.verify(hash, password),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_OPTIONS = {
|
|
13
|
+
memoryCost: 19456,
|
|
14
|
+
timeCost: 2,
|
|
15
|
+
parallelism: 1,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* argon2id-backed `PasswordHasherPort`. `verify` never throws on a malformed
|
|
19
|
+
* or foreign hash — it resolves `false`, so a corrupted `passwordHash` row
|
|
20
|
+
* degrades to "wrong password," never to a 500 or an authentication bypass.
|
|
21
|
+
*
|
|
22
|
+
* @complexity O(1) calls into the native binding; cost is tuned by
|
|
23
|
+
* `memoryCost`/`timeCost`/`parallelism`, not by input size.
|
|
24
|
+
* @overallScore 100
|
|
25
|
+
*/
|
|
26
|
+
export class Argon2PasswordHasher {
|
|
27
|
+
options;
|
|
28
|
+
loadBinding;
|
|
29
|
+
constructor({ loadBinding }, options = {}) {
|
|
30
|
+
this.loadBinding = loadBinding;
|
|
31
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
32
|
+
}
|
|
33
|
+
async hash({ password }) {
|
|
34
|
+
const argon2 = this.loadBinding({});
|
|
35
|
+
return argon2.hash({ password }, {
|
|
36
|
+
type: argon2.argon2id,
|
|
37
|
+
memoryCost: this.options.memoryCost,
|
|
38
|
+
timeCost: this.options.timeCost,
|
|
39
|
+
parallelism: this.options.parallelism,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async verify({ hash, password }) {
|
|
43
|
+
const argon2 = this.loadBinding({});
|
|
44
|
+
try {
|
|
45
|
+
return await argon2.verify({ hash, password });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Malformed/foreign hash (e.g. a hand-edited row) — fail closed, not 500.
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=hasher.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IdentityTransactionPort } from '../core/ports.js';
|
|
2
|
+
import type { UUID } from "@jini-ai/core/primitives";
|
|
3
|
+
/** Guarded writes never silently downgrade to independent asynchronous repository calls. */
|
|
4
|
+
export declare function inIdentityTransaction<T>(required: {
|
|
5
|
+
transactions: IdentityTransactionPort;
|
|
6
|
+
workspaceId: UUID;
|
|
7
|
+
execute: () => Promise<T>;
|
|
8
|
+
}): Promise<T>;
|
|
9
|
+
//# sourceMappingURL=identity-transaction.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { IdentityTransactionRequiredError } from '../core/types.js';
|
|
2
|
+
/** Guarded writes never silently downgrade to independent asynchronous repository calls. */
|
|
3
|
+
export function inIdentityTransaction(required) {
|
|
4
|
+
if (!required.transactions)
|
|
5
|
+
throw new IdentityTransactionRequiredError({});
|
|
6
|
+
return required.transactions.run({ workspaceId: required.workspaceId, execute: required.execute });
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=identity-transaction.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Node identity services and adapters; host wiring stays outside this package. */
|
|
2
|
+
export * from "./auth-service.js";
|
|
3
|
+
export * from "./authorize.js";
|
|
4
|
+
export * from "./grant-service.js";
|
|
5
|
+
export * from "./admin-crud-service.js";
|
|
6
|
+
export * from "./seed.js";
|
|
7
|
+
export * from "./repo.memory.js";
|
|
8
|
+
export * from "./repo.memory-transactions.js";
|
|
9
|
+
export * from "./password-policy.js";
|
|
10
|
+
export * from "./hasher.js";
|
|
11
|
+
export * from "./username.js";
|
|
12
|
+
export * from "./permission-migrations.js";
|
|
13
|
+
export { identityAgentToolCatalog, type IdentityAgentToolDefinition, } from "./agent-tools.js";
|
|
14
|
+
export { parseIdentityToolInput, type IdentityToolInputResult } from "./agent-tool-input.js";
|
|
15
|
+
export * from "./session-tokens.js";
|
|
16
|
+
export { buildIdentityRegistrations, identityDerivedRisk, type IdentityToolDeps } from "./agent-tool-registrations.js";
|
|
17
|
+
export { defaultUserManagementMessages, type UserManagementMessages } from "./messages.js";
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Node identity services and adapters; host wiring stays outside this package. */
|
|
2
|
+
// Hosts compose concrete repositories and inject their dependency bags. Exporting database-specific
|
|
3
|
+
// composition here would put one host's handle and schema in every consumer's dependency closure.
|
|
4
|
+
export * from "./auth-service.js";
|
|
5
|
+
export * from "./authorize.js";
|
|
6
|
+
export * from "./grant-service.js";
|
|
7
|
+
export * from "./admin-crud-service.js";
|
|
8
|
+
export * from "./seed.js";
|
|
9
|
+
export * from "./repo.memory.js";
|
|
10
|
+
export * from "./repo.memory-transactions.js";
|
|
11
|
+
export * from "./password-policy.js";
|
|
12
|
+
export * from "./hasher.js";
|
|
13
|
+
export * from "./username.js";
|
|
14
|
+
export * from "./permission-migrations.js";
|
|
15
|
+
export { identityAgentToolCatalog, } from "./agent-tools.js";
|
|
16
|
+
export { parseIdentityToolInput } from "./agent-tool-input.js";
|
|
17
|
+
export * from "./session-tokens.js";
|
|
18
|
+
export { buildIdentityRegistrations, identityDerivedRisk } from "./agent-tool-registrations.js";
|
|
19
|
+
export { defaultUserManagementMessages } from "./messages.js";
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Default user-facing authentication copy. Hosts may replace it without changing error classes. */
|
|
2
|
+
export interface UserManagementMessages {
|
|
3
|
+
invalidCredentials: string;
|
|
4
|
+
invalidSessionTtl: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const defaultUserManagementMessages: UserManagementMessages;
|
|
7
|
+
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Password policy: no minimum length, no composition rules — presence and an upper bound only.
|
|
3
|
+
*
|
|
4
|
+
* Owner decision: the previous NIST SP 800-63B-aligned 12-character minimum has been removed.
|
|
5
|
+
* This module no longer enforces composition rules (mandatory uppercase, digit, or symbol) either
|
|
6
|
+
* — that predates this change and remains deliberate, since composition rules measurably push
|
|
7
|
+
* people toward predictable, guessable patterns ("Password1!") rather than genuinely stronger
|
|
8
|
+
* secrets. What remains is the minimum a credential system can enforce at all: a password must be
|
|
9
|
+
* non-empty, and is capped at `MAX_PASSWORD_LENGTH` as a defense-in-depth bound (see that
|
|
10
|
+
* constant's own doc).
|
|
11
|
+
*
|
|
12
|
+
* `createUser` (`grant-service.ts`) and `resetUserPassword` (`admin-crud-service.ts`) are the
|
|
13
|
+
* identity system's only two write paths that ever set a password, confirmed by tracing the
|
|
14
|
+
* whole call graph — both call this module.
|
|
15
|
+
*
|
|
16
|
+
* Two things this deliberately does NOT do, both load-bearing:
|
|
17
|
+
* - Never reaches `seed.ts`'s first-boot owner password. `seedIdentity` hashes it directly
|
|
18
|
+
* (`deps.hasher.hash(ownerPassword)`, `seed.ts:317`) and calls neither `createUser` nor
|
|
19
|
+
* `resetUserPassword` — confirmed before writing this module. That separation is intentional
|
|
20
|
+
* to preserve here: the host chooses the first-boot owner password (`SeedIdentityInput` requires
|
|
21
|
+
* it and supplies no default), and an existing installation is already authenticating with
|
|
22
|
+
* whatever it chose. Enforcing this policy on that path would lock out every such installation
|
|
23
|
+
* whose chosen value predates the policy.
|
|
24
|
+
* - Never re-validates at login. This is a write-time policy — `login`/`verify` never call it.
|
|
25
|
+
* An account whose password predates this policy (or was seeded) must keep authenticating.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Generous upper bound — not itself a security requirement (argon2id's cost is tuned by its
|
|
29
|
+
* `memoryCost`/`timeCost`/`parallelism` parameters, not by input size — see `hasher.ts`'s own
|
|
30
|
+
* `@complexity` note), but a defense-in-depth cap on how large a string this service will accept
|
|
31
|
+
* and pass to the hasher at all, independent of whatever limit (if any) sits in front of it at
|
|
32
|
+
* the HTTP layer. Comfortably above any realistic human-typed passphrase.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_PASSWORD_LENGTH = 512;
|
|
35
|
+
/**
|
|
36
|
+
* Validates `password` against the presence-and-upper-bound-only policy.
|
|
37
|
+
*
|
|
38
|
+
* Measures length in Unicode CODE POINTS (`[...password].length`), not UTF-16 code units
|
|
39
|
+
* (`password.length`): a passphrase built from astral-plane characters (many emoji, some
|
|
40
|
+
* CJK/historic scripts) is represented as a surrogate pair per character in a JS string, so plain
|
|
41
|
+
* `.length` overcounts those characters relative to what a human typing them perceives — the same
|
|
42
|
+
* reasoning that applies to `MAX_PASSWORD_LENGTH` below applies to the emptiness check here, since
|
|
43
|
+
* both compare this same code-point count. No trimming, no character allowlist: every printable
|
|
44
|
+
* character, including leading/trailing spaces and full Unicode, is accepted as typed. Silently
|
|
45
|
+
* altering the input (trimming, stripping) would make the stored credential differ from the
|
|
46
|
+
* secret the operator actually chose.
|
|
47
|
+
*
|
|
48
|
+
* @returns `null` if `password` satisfies the policy, otherwise an operator-facing message
|
|
49
|
+
* stating the requirement plainly — used verbatim as the thrown `IdentityValidationError`'s
|
|
50
|
+
* message by both call sites, and rendered as-is through the admin's `describeApiError` layer.
|
|
51
|
+
*
|
|
52
|
+
* @complexity O(n) in the password's length — spreading a string into code points is a single
|
|
53
|
+
* linear pass, which is the minimum work a correct (non-UTF-16-code-unit) count requires.
|
|
54
|
+
* @overallScore 100
|
|
55
|
+
*/
|
|
56
|
+
export declare function validatePasswordPolicy({ password }: {
|
|
57
|
+
password: string;
|
|
58
|
+
}): string | null;
|
|
59
|
+
//# sourceMappingURL=password-policy.d.ts.map
|