@jini-ai/user-management 0.3.3 → 0.6.10
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 +15 -1
- package/dist/admin/adapters/memory.d.ts +3 -5
- package/dist/admin/adapters/memory.js +27 -28
- package/dist/admin/conformance/index.js +1 -0
- package/dist/admin/controllers/auth.controller.js +8 -6
- package/dist/admin/controllers/members.controller.js +9 -6
- package/dist/admin/controllers/roles.controller.js +99 -105
- package/dist/admin/controllers/users.controller.js +56 -36
- package/dist/admin/error-copy.d.ts +11 -0
- package/dist/admin/error-copy.js +10 -0
- package/dist/admin/people.rules.js +37 -31
- package/dist/admin/react/components/MemberRow.d.ts +1 -1
- package/dist/admin/react/components/MemberRow.js +12 -2
- package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
- package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
- package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
- package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
- package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
- package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
- package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
- package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
- package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
- package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
- package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
- package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
- package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
- package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
- package/dist/admin/react/index.d.ts +4 -4
- package/dist/admin/react/pages/LoginPage.d.ts +1 -1
- package/dist/admin/react/pages/MembersPage.d.ts +1 -1
- package/dist/admin/react/pages/RolesPage.d.ts +2 -2
- package/dist/admin/react/pages/RolesPage.js +6 -2
- package/dist/admin/react/pages/UsersPage.d.ts +2 -2
- package/dist/admin/react/pages/UsersPage.js +6 -2
- package/dist/admin/rules.js +14 -12
- package/dist/react/errors.js +16 -9
- package/dist/react/index.d.ts +1 -1
- package/dist/react/members/hooks/use-members.hooks.js +21 -12
- package/dist/react/testing.js +4 -6
- package/dist/react/users/UserRowParts.d.ts +53 -0
- package/dist/react/users/UserRowParts.js +39 -0
- package/dist/react/users/Users.d.ts +31 -2
- package/dist/react/users/Users.js +41 -36
- package/dist/react/users/hooks/use-users.hooks.js +57 -30
- package/dist/server/admin-crud-service.js +25 -13
- package/dist/server/agent-tool-input.js +62 -36
- package/dist/server/agent-tool-registrations.d.ts +46 -0
- package/dist/server/agent-tool-registrations.js +28 -20
- package/dist/server/auth-service.js +35 -20
- package/dist/server/authorize.js +7 -9
- package/dist/server/permission-migrations.js +35 -25
- package/dist/server/repo.memory-transactions.js +5 -6
- package/dist/server/repo.memory.js +35 -81
- package/dist/server/seed.js +100 -84
- package/package.json +12 -12
|
@@ -33,6 +33,9 @@ const STRING_KEYWORDS = new Set(["type", "minLength", "description"]);
|
|
|
33
33
|
* that ordering a schema could publish, say, a `pattern` the parser silently ignores, and every
|
|
34
34
|
* test using a conforming fixture would still pass.
|
|
35
35
|
*
|
|
36
|
+
* Check order (pinned in `__tests__/agent-tool-input.pins.test.ts`): object shape, top-level
|
|
37
|
+
* keywords, `type`, `additionalProperties`, `properties`, `required`, then each property sub-schema.
|
|
38
|
+
*
|
|
36
39
|
* @param schema - A tool's published `inputSchema`.
|
|
37
40
|
* @throws {Error} If the schema, or any property sub-schema, uses an unimplemented keyword.
|
|
38
41
|
* @complexity O(k) in the total keyword count across the schema and its property sub-schemas.
|
|
@@ -51,27 +54,42 @@ function assertSchemaIsInterpretable(schema) {
|
|
|
51
54
|
const properties = Object.hasOwn(schema, "properties") ? schema.properties : {};
|
|
52
55
|
if (!isSchemaObject(properties))
|
|
53
56
|
throw new Error("identity agent-tool schema properties must be an object");
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
assertRequiredIsDeclared(Object.hasOwn(schema, "required") ? schema.required : [], properties);
|
|
58
|
+
for (const [key, sub] of Object.entries(properties))
|
|
59
|
+
assertStringPropertySchema(key, sub);
|
|
60
|
+
}
|
|
61
|
+
/** `required` must be unique strings, each naming an OWN declared property. */
|
|
62
|
+
function assertRequiredIsDeclared(required, properties) {
|
|
63
|
+
if (!isUniqueStringArray(required)) {
|
|
56
64
|
throw new Error("identity agent-tool schema required must be an array of unique strings");
|
|
57
65
|
}
|
|
58
66
|
for (const key of required) {
|
|
59
67
|
if (!Object.hasOwn(properties, key))
|
|
60
68
|
throw new Error(`identity agent-tool schema requires undeclared property '${key}'`);
|
|
61
69
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
}
|
|
71
|
+
function isUniqueStringArray(value) {
|
|
72
|
+
return Array.isArray(value) && value.every(key => typeof key === "string") && new Set(value).size === value.length;
|
|
73
|
+
}
|
|
74
|
+
/** A property sub-schema must be a string schema whose optional `minLength` is a nonnegative integer. */
|
|
75
|
+
function assertStringPropertySchema(key, sub) {
|
|
76
|
+
if (!isSchemaObject(sub))
|
|
77
|
+
throw new Error(`identity agent-tool schema property '${key}' must be an object`);
|
|
78
|
+
assertKeywords(sub, STRING_KEYWORDS);
|
|
79
|
+
if (sub.type !== "string") {
|
|
80
|
+
throw new Error(`identity agent-tool schema declares non-string property '${key}' — this parser only implements string properties`);
|
|
81
|
+
}
|
|
82
|
+
if (Object.hasOwn(sub, "minLength") && !isNonNegativeInteger(sub.minLength)) {
|
|
83
|
+
throw new Error(`identity agent-tool schema minLength for '${key}' must be a nonnegative integer`);
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
|
-
|
|
86
|
+
function isNonNegativeInteger(value) {
|
|
87
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* JSON Schema objects and keyword placement are checked before inspecting any input value. The
|
|
91
|
+
* same "non-null, non-array object" test also decides whether an input is an object at all.
|
|
92
|
+
*/
|
|
75
93
|
function isSchemaObject(value) {
|
|
76
94
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
95
|
}
|
|
@@ -110,39 +128,47 @@ function assertKeywords(schema, supported) {
|
|
|
110
128
|
*/
|
|
111
129
|
export function parseIdentityToolInput(required) {
|
|
112
130
|
const schema = required.schema;
|
|
131
|
+
// Also guarantees `type === "object"` and `additionalProperties === false`, read through the
|
|
132
|
+
// same property access used below, so neither needs re-checking here.
|
|
113
133
|
assertSchemaIsInterpretable(schema);
|
|
114
|
-
if (schema.type !== "object") {
|
|
115
|
-
throw new Error(`identity agent-tool schema must be an object schema, got type '${String(schema.type)}'`);
|
|
116
|
-
}
|
|
117
134
|
const { input } = required;
|
|
118
|
-
if (
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
if (!isSchemaObject(input))
|
|
136
|
+
return rejected("input must be an object");
|
|
137
|
+
// Read through ordinary property access, exactly as before validation was split out — see the
|
|
138
|
+
// "inherited schema members (reported, not fixed)" pins for what that means for a prototype.
|
|
122
139
|
const properties = (schema.properties ?? {});
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
const violation = findUnrecognizedKey(input, properties)
|
|
141
|
+
?? findMissingRequiredKey(input, schema.required ?? []);
|
|
142
|
+
if (violation !== undefined)
|
|
143
|
+
return rejected(violation);
|
|
144
|
+
return extractStrings(input, properties);
|
|
145
|
+
}
|
|
146
|
+
function rejected(message) {
|
|
147
|
+
return { ok: false, error: new Error(message) };
|
|
148
|
+
}
|
|
149
|
+
/** Own input keys only; a key inherited through the input's prototype is neither rejected nor read. */
|
|
150
|
+
function findUnrecognizedKey(record, properties) {
|
|
151
|
+
const key = Object.keys(record).find(candidate => !Object.hasOwn(properties, candidate));
|
|
152
|
+
return key === undefined ? undefined : `'${key}' is not a recognized input key`;
|
|
153
|
+
}
|
|
154
|
+
/** A required key present with the value `undefined` counts as missing. */
|
|
155
|
+
function findMissingRequiredKey(record, requiredKeys) {
|
|
156
|
+
const key = requiredKeys.find(candidate => !Object.hasOwn(record, candidate) || record[candidate] === undefined);
|
|
157
|
+
return key === undefined ? undefined : `'${key}' is required`;
|
|
158
|
+
}
|
|
159
|
+
/** Extracts every present declared key, in declaration order, rejecting the first non-conforming value. */
|
|
160
|
+
function extractStrings(record, properties) {
|
|
135
161
|
const value = {};
|
|
136
162
|
for (const [key, sub] of Object.entries(properties)) {
|
|
137
163
|
if (!Object.hasOwn(record, key))
|
|
138
164
|
continue;
|
|
139
165
|
const candidate = record[key];
|
|
140
|
-
if (typeof candidate !== "string")
|
|
141
|
-
return
|
|
142
|
-
}
|
|
166
|
+
if (typeof candidate !== "string")
|
|
167
|
+
return rejected(`'${key}' must be a string, received ${describeType(candidate)}`);
|
|
143
168
|
if (typeof sub.minLength === "number" && Array.from(candidate).length < sub.minLength) {
|
|
144
|
-
return
|
|
169
|
+
return rejected(`'${key}' must contain at least ${sub.minLength} Unicode code point(s)`);
|
|
145
170
|
}
|
|
171
|
+
// defineProperty keeps a `__proto__` key as plain own data rather than invoking the setter.
|
|
146
172
|
Object.defineProperty(value, key, { value: candidate, enumerable: true, writable: true, configurable: true });
|
|
147
173
|
}
|
|
148
174
|
return { ok: true, value };
|
|
@@ -13,6 +13,7 @@ import type { Clock, IdGenerator } from "@jini-ai/core/primitives";
|
|
|
13
13
|
* `assistant/__tests__/tool-registrations.identity-authorization.test.ts`.
|
|
14
14
|
*/
|
|
15
15
|
import { type DerivedRiskByToolId, type ToolRegistration } from "@jini-ai/core";
|
|
16
|
+
import { type IdentityAgentToolDefinition } from "./agent-tools.js";
|
|
16
17
|
import type { AuthServiceDeps } from "./auth-service.js";
|
|
17
18
|
import type { IdentityRepos, PasswordHasherPort, PolicyPermissionRepoPort, PolicyRepoPort, PrincipalPolicyRepoPort, PrincipalRepoPort, PrincipalRoleRepoPort, RolePolicyRepoPort, RoleRepoPort, SessionRepoPort, UserRepoPort } from "../core/index.js";
|
|
18
19
|
/**
|
|
@@ -48,5 +49,50 @@ export interface IdentityToolDeps {
|
|
|
48
49
|
* `sideEffects` declaration.
|
|
49
50
|
*/
|
|
50
51
|
export declare const identityDerivedRisk: DerivedRiskByToolId;
|
|
52
|
+
/**
|
|
53
|
+
* Catalog lookup that fails loudly on drift between a handler's tool id and the published catalog.
|
|
54
|
+
* Exported for this module's tests only — the package entry does not re-export it.
|
|
55
|
+
*/
|
|
56
|
+
export declare function requireIdentityCatalogEntry({ toolId }: {
|
|
57
|
+
toolId: string;
|
|
58
|
+
}): IdentityAgentToolDefinition;
|
|
59
|
+
/**
|
|
60
|
+
* Validates `input` through the tool's OWN published `inputSchema` (interpreted by
|
|
61
|
+
* `agent-tool-input.ts`), then re-throws any rejection with that schema appended.
|
|
62
|
+
*
|
|
63
|
+
* Identity is the one domain whose published schema is ALSO the enforcing parser, so this does not
|
|
64
|
+
* use the kit's `withSchemaOnRejection`: there is no domain call to wrap, the rejection is produced
|
|
65
|
+
* here. Same error-recovery contract either way — a model that gets back only "'roleId' is
|
|
66
|
+
* required" must guess the rest of the shape, whereas one that gets the schema with it can correct
|
|
67
|
+
* the call in a single turn.
|
|
68
|
+
*
|
|
69
|
+
* @complexity O(p) in the tool's declared property count.
|
|
70
|
+
* @overallScore 100
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* A validated tool input, with required and optional reads spelled differently.
|
|
74
|
+
*
|
|
75
|
+
* The distinction is not decoration. `parseIdentityToolInput` returns a `Record<string, string>`
|
|
76
|
+
* whose keys are whatever the caller supplied and the schema accepted, so nothing in the type says
|
|
77
|
+
* which fields are guaranteed. Reading them all the same way makes a schema that forgot to mark a
|
|
78
|
+
* field `required` indistinguishable from one that did — and the failure mode is silent: the
|
|
79
|
+
* handler passes `undefined` into a service call, which stores it. Splitting the read means the
|
|
80
|
+
* handler states which fields it is relying on, and a schema/handler disagreement throws at the
|
|
81
|
+
* boundary naming the field.
|
|
82
|
+
*/
|
|
83
|
+
export interface ValidatedIdentityInput {
|
|
84
|
+
/** A field the tool's published `inputSchema` marks `required`. @throws {Error} If absent. */
|
|
85
|
+
req(field: string): string;
|
|
86
|
+
/** A field the schema leaves optional. `undefined` means the caller omitted it. */
|
|
87
|
+
opt(field: string): string | undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parses `input` against `toolId`'s published schema and returns the split required/optional reader.
|
|
91
|
+
* Exported for this module's tests only — the package entry does not re-export it.
|
|
92
|
+
*/
|
|
93
|
+
export declare function identityInput({ toolId, input }: {
|
|
94
|
+
toolId: string;
|
|
95
|
+
input: unknown;
|
|
96
|
+
}): ValidatedIdentityInput;
|
|
51
97
|
export declare function buildIdentityRegistrations(routeDeps: IdentityToolDeps): ToolRegistration[];
|
|
52
98
|
//# sourceMappingURL=agent-tool-registrations.d.ts.map
|
|
@@ -102,14 +102,22 @@ export const identityDerivedRisk = new Map([
|
|
|
102
102
|
* work with what it got and knows not to treat the list as complete.
|
|
103
103
|
*/
|
|
104
104
|
const IDENTITY_USER_LIST_MAX = 200;
|
|
105
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Catalog lookup that fails loudly on drift between a handler's tool id and the published catalog.
|
|
107
|
+
* Exported for this module's tests only — the package entry does not re-export it.
|
|
108
|
+
*/
|
|
109
|
+
export function requireIdentityCatalogEntry({ toolId }) {
|
|
106
110
|
const entry = CATALOG_BY_ID.get(toolId);
|
|
107
111
|
if (!entry)
|
|
108
112
|
throw new Error(`agent-tool-registrations: catalog has no entry named '${toolId}' — @jini-ai/user-management/server catalog drifted`);
|
|
109
113
|
return entry;
|
|
110
114
|
}
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Parses `input` against `toolId`'s published schema and returns the split required/optional reader.
|
|
117
|
+
* Exported for this module's tests only — the package entry does not re-export it.
|
|
118
|
+
*/
|
|
119
|
+
export function identityInput({ toolId, input }) {
|
|
120
|
+
const entry = requireIdentityCatalogEntry({ toolId });
|
|
113
121
|
const parsed = parseIdentityToolInput({ schema: entry.inputSchema, input });
|
|
114
122
|
if (!parsed.ok) {
|
|
115
123
|
const recovery = "Fix the input and retry — this will not resolve on retry without an input change.";
|
|
@@ -131,7 +139,7 @@ function identityInput(toolId, input) {
|
|
|
131
139
|
}
|
|
132
140
|
/** The permission set a tool's catalog entry declares — both halves when the gate is an OR. */
|
|
133
141
|
function identityPermissionsFor(toolId) {
|
|
134
|
-
const { authorization } = requireIdentityCatalogEntry(toolId);
|
|
142
|
+
const { authorization } = requireIdentityCatalogEntry({ toolId });
|
|
135
143
|
return authorization.orPermission ? [authorization.permission, authorization.orPermission] : [authorization.permission];
|
|
136
144
|
}
|
|
137
145
|
/**
|
|
@@ -225,7 +233,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
225
233
|
const serviceDeps = () => identityServiceDepsFrom(routeDeps);
|
|
226
234
|
const handlers = {
|
|
227
235
|
identity_user_list: async (ctx) => {
|
|
228
|
-
identityInput("identity_user_list", ctx.input);
|
|
236
|
+
identityInput({ toolId: "identity_user_list", input: ctx.input });
|
|
229
237
|
await assertIdentityReadAllowed(routeDeps, "identity_user_list", ctx.principal.id);
|
|
230
238
|
const principals = await routeDeps.principalRepo.list({ workspaceId: routeDeps.workspaceId });
|
|
231
239
|
// Humans only — `system`/`agent`/`api_key` principals (including the disabled `user-local`
|
|
@@ -244,19 +252,19 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
244
252
|
: { users: listed };
|
|
245
253
|
},
|
|
246
254
|
identity_role_list: async (ctx) => {
|
|
247
|
-
identityInput("identity_role_list", ctx.input);
|
|
255
|
+
identityInput({ toolId: "identity_role_list", input: ctx.input });
|
|
248
256
|
await assertIdentityReadAllowed(routeDeps, "identity_role_list", ctx.principal.id);
|
|
249
257
|
const roles = await routeDeps.roleRepo.list({ workspaceId: routeDeps.workspaceId });
|
|
250
258
|
return { roles: roles.map(toIdentityRoleView) };
|
|
251
259
|
},
|
|
252
260
|
identity_policy_list: async (ctx) => {
|
|
253
|
-
identityInput("identity_policy_list", ctx.input);
|
|
261
|
+
identityInput({ toolId: "identity_policy_list", input: ctx.input });
|
|
254
262
|
await assertIdentityReadAllowed(routeDeps, "identity_policy_list", ctx.principal.id);
|
|
255
263
|
const policies = await routeDeps.policyRepo.list({ workspaceId: routeDeps.workspaceId });
|
|
256
264
|
return { policies: policies.map(toIdentityPolicyView) };
|
|
257
265
|
},
|
|
258
266
|
identity_user_create: async (ctx) => {
|
|
259
|
-
const input = identityInput("identity_user_create", ctx.input);
|
|
267
|
+
const input = identityInput({ toolId: "identity_user_create", input: ctx.input });
|
|
260
268
|
const { principal, user } = await createUser({
|
|
261
269
|
deps: serviceDeps(),
|
|
262
270
|
input: {
|
|
@@ -270,7 +278,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
270
278
|
return { user: toIdentityUserView({ principal, user, roleIds: [] }) };
|
|
271
279
|
},
|
|
272
280
|
identity_user_update_email: async (ctx) => {
|
|
273
|
-
const input = identityInput("identity_user_update_email", ctx.input);
|
|
281
|
+
const input = identityInput({ toolId: "identity_user_update_email", input: ctx.input });
|
|
274
282
|
const { user } = await updateUser({
|
|
275
283
|
deps: serviceDeps(),
|
|
276
284
|
input: {
|
|
@@ -287,7 +295,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
287
295
|
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
|
|
288
296
|
},
|
|
289
297
|
identity_user_change_username: async (ctx) => {
|
|
290
|
-
const input = identityInput("identity_user_change_username", ctx.input);
|
|
298
|
+
const input = identityInput({ toolId: "identity_user_change_username", input: ctx.input });
|
|
291
299
|
const principalId = input.req("principalId");
|
|
292
300
|
const { user } = await updateUser({ deps: serviceDeps(), input: {
|
|
293
301
|
workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
|
|
@@ -298,7 +306,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
298
306
|
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principalId) }) };
|
|
299
307
|
},
|
|
300
308
|
identity_user_disable: async (ctx) => {
|
|
301
|
-
const input = identityInput("identity_user_disable", ctx.input);
|
|
309
|
+
const input = identityInput({ toolId: "identity_user_disable", input: ctx.input });
|
|
302
310
|
const { principal } = await disablePrincipal({
|
|
303
311
|
deps: serviceDeps(),
|
|
304
312
|
input: {
|
|
@@ -314,7 +322,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
314
322
|
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
|
|
315
323
|
},
|
|
316
324
|
identity_user_enable: async (ctx) => {
|
|
317
|
-
const input = identityInput("identity_user_enable", ctx.input);
|
|
325
|
+
const input = identityInput({ toolId: "identity_user_enable", input: ctx.input });
|
|
318
326
|
const { principal } = await enablePrincipal({
|
|
319
327
|
deps: serviceDeps(),
|
|
320
328
|
input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId: input.req("principalId") },
|
|
@@ -323,7 +331,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
323
331
|
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
|
|
324
332
|
},
|
|
325
333
|
identity_role_create: async (ctx) => {
|
|
326
|
-
const input = identityInput("identity_role_create", ctx.input);
|
|
334
|
+
const input = identityInput({ toolId: "identity_role_create", input: ctx.input });
|
|
327
335
|
const { role } = await createRole({
|
|
328
336
|
deps: serviceDeps(),
|
|
329
337
|
input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, name: input.req("name") },
|
|
@@ -331,7 +339,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
331
339
|
return { role: toIdentityRoleView(role) };
|
|
332
340
|
},
|
|
333
341
|
identity_role_assign: async (ctx) => {
|
|
334
|
-
const input = identityInput("identity_role_assign", ctx.input);
|
|
342
|
+
const input = identityInput({ toolId: "identity_role_assign", input: ctx.input });
|
|
335
343
|
const { assignment } = await assignRole({
|
|
336
344
|
deps: serviceDeps(),
|
|
337
345
|
input: {
|
|
@@ -346,7 +354,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
346
354
|
return { assigned: { principalId: assignment.principalId, roleId: assignment.roleId } };
|
|
347
355
|
},
|
|
348
356
|
identity_role_rename: async (ctx) => {
|
|
349
|
-
const input = identityInput("identity_role_rename", ctx.input);
|
|
357
|
+
const input = identityInput({ toolId: "identity_role_rename", input: ctx.input });
|
|
350
358
|
const { role } = await updateRole({
|
|
351
359
|
deps: serviceDeps(),
|
|
352
360
|
input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, roleId: input.req("roleId"), name: input.req("name") },
|
|
@@ -354,7 +362,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
354
362
|
return { role: toIdentityRoleView(role) };
|
|
355
363
|
},
|
|
356
364
|
identity_role_delete: async (ctx) => {
|
|
357
|
-
const input = identityInput("identity_role_delete", ctx.input);
|
|
365
|
+
const input = identityInput({ toolId: "identity_role_delete", input: ctx.input });
|
|
358
366
|
await deleteRole({
|
|
359
367
|
deps: serviceDeps(),
|
|
360
368
|
input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, roleId: input.req("roleId") },
|
|
@@ -364,7 +372,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
364
372
|
return { deleted: { roleId: input.req("roleId") } };
|
|
365
373
|
},
|
|
366
374
|
identity_policy_create: async (ctx) => {
|
|
367
|
-
const input = identityInput("identity_policy_create", ctx.input);
|
|
375
|
+
const input = identityInput({ toolId: "identity_policy_create", input: ctx.input });
|
|
368
376
|
const { policy } = await createPolicy({
|
|
369
377
|
deps: serviceDeps(),
|
|
370
378
|
input: {
|
|
@@ -379,7 +387,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
379
387
|
return { policy: toIdentityPolicyView(policy) };
|
|
380
388
|
},
|
|
381
389
|
identity_policy_update: async (ctx) => {
|
|
382
|
-
const input = identityInput("identity_policy_update", ctx.input);
|
|
390
|
+
const input = identityInput({ toolId: "identity_policy_update", input: ctx.input });
|
|
383
391
|
const { policy } = await updatePolicy({
|
|
384
392
|
deps: serviceDeps(),
|
|
385
393
|
input: {
|
|
@@ -393,7 +401,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
393
401
|
return { policy: toIdentityPolicyView(policy) };
|
|
394
402
|
},
|
|
395
403
|
identity_policy_delete: async (ctx) => {
|
|
396
|
-
const input = identityInput("identity_policy_delete", ctx.input);
|
|
404
|
+
const input = identityInput({ toolId: "identity_policy_delete", input: ctx.input });
|
|
397
405
|
await deletePolicy({
|
|
398
406
|
deps: serviceDeps(),
|
|
399
407
|
input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, policyId: input.req("policyId") },
|
|
@@ -403,7 +411,7 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
403
411
|
return { deleted: { policyId: input.req("policyId") } };
|
|
404
412
|
},
|
|
405
413
|
identity_policy_attach: async (ctx) => {
|
|
406
|
-
const input = identityInput("identity_policy_attach", ctx.input);
|
|
414
|
+
const input = identityInput({ toolId: "identity_policy_attach", input: ctx.input });
|
|
407
415
|
const { attachment } = await attachPolicy({
|
|
408
416
|
deps: serviceDeps(),
|
|
409
417
|
input: {
|
|
@@ -63,6 +63,38 @@ export async function createSessionForPrincipal(required, optional = {}) {
|
|
|
63
63
|
await deps.repos.sessions.save(session);
|
|
64
64
|
return { session, rawToken };
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The one status that may log in or keep a live session: the principal exists AND is `active`.
|
|
68
|
+
* Shared by `login` and `validateSession` so the two fail-closed checks cannot drift apart.
|
|
69
|
+
*/
|
|
70
|
+
function isActivePrincipal(principal) {
|
|
71
|
+
return principal?.status === "active";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The credential checks of `login`, in their fixed order: blank input is refused before any lookup,
|
|
75
|
+
* and the (slow) hash verify runs only once a live principal is found. Every refusal throws the same
|
|
76
|
+
* `AuthInvalidCredentialsError` message — no user-enumeration signal.
|
|
77
|
+
*/
|
|
78
|
+
async function verifyCredentials(required) {
|
|
79
|
+
const { deps, input, invalidCredentialsMessage } = required;
|
|
80
|
+
const refuse = () => new AuthInvalidCredentialsError({ message: invalidCredentialsMessage });
|
|
81
|
+
const username = normalizeUsername({ raw: input.username });
|
|
82
|
+
if (!username || !input.password)
|
|
83
|
+
throw refuse();
|
|
84
|
+
const userRow = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
|
|
85
|
+
if (!userRow)
|
|
86
|
+
throw refuse();
|
|
87
|
+
const principal = await deps.repos.principals.findById({
|
|
88
|
+
workspaceId: input.workspaceId,
|
|
89
|
+
id: userRow.principalId,
|
|
90
|
+
});
|
|
91
|
+
if (!isActivePrincipal(principal))
|
|
92
|
+
throw refuse();
|
|
93
|
+
const passwordOk = await deps.hasher.verify({ hash: userRow.passwordHash, password: input.password });
|
|
94
|
+
if (!passwordOk)
|
|
95
|
+
throw refuse();
|
|
96
|
+
return { userRow, principal };
|
|
97
|
+
}
|
|
66
98
|
/**
|
|
67
99
|
* Verify `username`+`password` for an `active` principal and mint a session
|
|
68
100
|
* Constant-shaped failure: a nonexistent username, a
|
|
@@ -77,25 +109,8 @@ export async function createSessionForPrincipal(required, optional = {}) {
|
|
|
77
109
|
export async function login(required, optional = {}) {
|
|
78
110
|
const { deps } = required;
|
|
79
111
|
const input = { ...required.input, ...optional };
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
|
|
83
|
-
}
|
|
84
|
-
const userRow = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
|
|
85
|
-
if (!userRow) {
|
|
86
|
-
throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
|
|
87
|
-
}
|
|
88
|
-
const principal = await deps.repos.principals.findById({
|
|
89
|
-
workspaceId: input.workspaceId,
|
|
90
|
-
id: userRow.principalId,
|
|
91
|
-
});
|
|
92
|
-
if (!principal || principal.status !== "active") {
|
|
93
|
-
throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
|
|
94
|
-
}
|
|
95
|
-
const passwordOk = await deps.hasher.verify({ hash: userRow.passwordHash, password: input.password });
|
|
96
|
-
if (!passwordOk) {
|
|
97
|
-
throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
|
|
98
|
-
}
|
|
112
|
+
const invalidCredentialsMessage = (optional.messages ?? defaultUserManagementMessages).invalidCredentials;
|
|
113
|
+
const { userRow, principal } = await verifyCredentials({ deps, input, invalidCredentialsMessage });
|
|
99
114
|
const { session, rawToken } = await createSessionForPrincipal({ deps, input: { workspaceId: input.workspaceId, principalId: principal.id } }, { ip: input.ip, userAgent: input.userAgent, sessionTtlMs: optional.sessionTtlMs, messages: optional.messages });
|
|
100
115
|
// Reuses the session's own `createdAt` rather than a second `kernelNowIso({ clock: clock })` call — both are
|
|
101
116
|
// meant to be the same instant, and reading one avoids two clock reads ever disagreeing.
|
|
@@ -133,7 +148,7 @@ export async function validateSession(required, optional = {}) {
|
|
|
133
148
|
id: session.principalId,
|
|
134
149
|
});
|
|
135
150
|
// a disabled principal's live session stops validating immediately. See docs/decisions/DR-001-identity-and-session-boundary.md.
|
|
136
|
-
if (!principal
|
|
151
|
+
if (!isActivePrincipal(principal))
|
|
137
152
|
return null;
|
|
138
153
|
return { principal, session };
|
|
139
154
|
}
|
package/dist/server/authorize.js
CHANGED
|
@@ -29,11 +29,10 @@ export async function resolveEffectivePermissions(required) {
|
|
|
29
29
|
* True iff `row` is an exact, unconstrained-or-scope-matching grant of
|
|
30
30
|
* `permission` in `context` (state.spec §4 `matchesRow`). Assumes `row` has
|
|
31
31
|
* already passed the owner-wildcard check upstream — this never special-cases
|
|
32
|
-
* `"*"`.
|
|
32
|
+
* `"*"`. Callers pass only rows already filtered to `permission` (see
|
|
33
|
+
* `authorize`'s `candidates`), so the permission equality is not re-checked here.
|
|
33
34
|
*/
|
|
34
|
-
function matchesRow(row,
|
|
35
|
-
if (row.permission !== permission)
|
|
36
|
-
return false;
|
|
35
|
+
function matchesRow(row, context) {
|
|
37
36
|
if (row.constraintJson != null)
|
|
38
37
|
return false;
|
|
39
38
|
if (row.resourceType != null && row.resourceType !== context.entityType)
|
|
@@ -89,16 +88,15 @@ export async function authorize(required, optional = {}) {
|
|
|
89
88
|
if (candidates.length === 0) {
|
|
90
89
|
return { allowed: false, reason: "no_grant" };
|
|
91
90
|
}
|
|
92
|
-
const matched = candidates.some((row) => matchesRow(row,
|
|
91
|
+
const matched = candidates.some((row) => matchesRow(row, context));
|
|
93
92
|
if (matched) {
|
|
94
93
|
return { allowed: true, reason: "matched" };
|
|
95
94
|
}
|
|
96
95
|
if (candidates.some((row) => row.constraintJson != null)) {
|
|
97
96
|
return { allowed: false, reason: "unconstrained_deny" };
|
|
98
97
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
return { allowed: false, reason: "no_grant" };
|
|
98
|
+
// A candidate fails matchesRow only through a constraint or a mismatched resource scope; with
|
|
99
|
+
// no constraint left, every candidate carries a resource scope that differs from the context.
|
|
100
|
+
return { allowed: false, reason: "resource_scope_mismatch" };
|
|
103
101
|
}
|
|
104
102
|
//# sourceMappingURL=authorize.js.map
|
|
@@ -18,6 +18,39 @@ export function createPermissionMigrationRegistry(_required = {}, optional = {})
|
|
|
18
18
|
register(migration);
|
|
19
19
|
return { register, list: () => [...byFrom.values()] };
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* One policy's share of the fan-out: for each pair whose `from` the policy holds, write every `to`
|
|
23
|
+
* it does not hold yet. `held` is updated as rows are written, so a target named by two pairs is
|
|
24
|
+
* written once; pairs run in list order in a single pass, so a target written here is never then
|
|
25
|
+
* re-read as a later pair's `from` within this same policy. Additive-only — only `save` is called.
|
|
26
|
+
*
|
|
27
|
+
* @returns how many rows were written for this policy.
|
|
28
|
+
*/
|
|
29
|
+
async function grantMissingTargets(required) {
|
|
30
|
+
const { deps, policyId } = required;
|
|
31
|
+
const existing = await deps.policyPermissions.listByPolicyId({ workspaceId: deps.workspaceId, policyId });
|
|
32
|
+
const held = new Set(existing.map((row) => row.permission));
|
|
33
|
+
let written = 0;
|
|
34
|
+
for (const migration of deps.migrations) {
|
|
35
|
+
if (!held.has(migration.from))
|
|
36
|
+
continue;
|
|
37
|
+
for (const toPermission of migration.to) {
|
|
38
|
+
if (held.has(toPermission))
|
|
39
|
+
continue;
|
|
40
|
+
await deps.policyPermissions.save({
|
|
41
|
+
id: deps.idGen.newId(),
|
|
42
|
+
workspaceId: deps.workspaceId,
|
|
43
|
+
policyId,
|
|
44
|
+
permission: toPermission,
|
|
45
|
+
resourceType: null,
|
|
46
|
+
constraintJson: null,
|
|
47
|
+
});
|
|
48
|
+
held.add(toPermission);
|
|
49
|
+
written += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return written;
|
|
53
|
+
}
|
|
21
54
|
/**
|
|
22
55
|
* For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
|
|
23
56
|
* holding `from`, ensure every string in `to` is also granted — adding only
|
|
@@ -36,8 +69,7 @@ export function createPermissionMigrationRegistry(_required = {}, optional = {})
|
|
|
36
69
|
* See docs/decisions/DR-003-additive-permission-migration.md.
|
|
37
70
|
*/
|
|
38
71
|
export async function migrateDeprecatedPermissionGrants(deps) {
|
|
39
|
-
|
|
40
|
-
if (migrations.length === 0)
|
|
72
|
+
if (deps.migrations.length === 0)
|
|
41
73
|
return { migratedGrantCount: 0 };
|
|
42
74
|
return inIdentityTransaction({
|
|
43
75
|
transactions: deps.transactions,
|
|
@@ -46,29 +78,7 @@ export async function migrateDeprecatedPermissionGrants(deps) {
|
|
|
46
78
|
const policies = await deps.policies.list({ workspaceId: deps.workspaceId });
|
|
47
79
|
let migratedGrantCount = 0;
|
|
48
80
|
for (const policy of policies) {
|
|
49
|
-
|
|
50
|
-
workspaceId: deps.workspaceId,
|
|
51
|
-
policyId: policy.id,
|
|
52
|
-
});
|
|
53
|
-
const held = new Set(existing.map((row) => row.permission));
|
|
54
|
-
for (const migration of migrations) {
|
|
55
|
-
if (!held.has(migration.from))
|
|
56
|
-
continue;
|
|
57
|
-
for (const toPermission of migration.to) {
|
|
58
|
-
if (held.has(toPermission))
|
|
59
|
-
continue;
|
|
60
|
-
await deps.policyPermissions.save({
|
|
61
|
-
id: deps.idGen.newId(),
|
|
62
|
-
workspaceId: deps.workspaceId,
|
|
63
|
-
policyId: policy.id,
|
|
64
|
-
permission: toPermission,
|
|
65
|
-
resourceType: null,
|
|
66
|
-
constraintJson: null,
|
|
67
|
-
});
|
|
68
|
-
held.add(toPermission);
|
|
69
|
-
migratedGrantCount += 1;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
81
|
+
migratedGrantCount += await grantMissingTargets({ deps, policyId: policy.id });
|
|
72
82
|
}
|
|
73
83
|
return { migratedGrantCount };
|
|
74
84
|
},
|
|
@@ -12,12 +12,12 @@ export function createTransactionalInMemoryIdentityRepos(required) {
|
|
|
12
12
|
throw new Error('identity repositories are already bound to a transaction port');
|
|
13
13
|
const context = new AsyncLocalStorage();
|
|
14
14
|
let tail = Promise.resolve();
|
|
15
|
-
const stores = Object.entries(required.repos).filter(([key]) => key !== 'transactions')
|
|
16
|
-
for (const [, repo] of stores) {
|
|
15
|
+
const stores = Object.entries(required.repos).filter(([key]) => key !== 'transactions').map(([, repo]) => {
|
|
17
16
|
if (!(repo instanceof InMemoryIdentityRows)) {
|
|
18
17
|
throw new Error('memory transactions require in-memory identity repositories');
|
|
19
18
|
}
|
|
20
|
-
|
|
19
|
+
return repo;
|
|
20
|
+
});
|
|
21
21
|
/** Preserve queue progress after rejection; descendants of a completed body cannot bypass it. */
|
|
22
22
|
async function exclusive(execute) {
|
|
23
23
|
if (context.getStore()?.active)
|
|
@@ -37,9 +37,8 @@ export function createTransactionalInMemoryIdentityRepos(required) {
|
|
|
37
37
|
}
|
|
38
38
|
const transactions = {
|
|
39
39
|
run: ({ execute }) => exclusive(async () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
throw new Error('memory transactions require in-memory identity repositories');
|
|
40
|
+
// Every store was checked to be InMemoryIdentityRows when the bag was bound above.
|
|
41
|
+
const snapshots = stores.map((repo) => {
|
|
43
42
|
const rows = repo.snapshotRows({});
|
|
44
43
|
return { restore: () => repo.restoreRows({ rows }) };
|
|
45
44
|
});
|