@memberjunction/core-entities-server 6.1.0-edge.5 → 6.1.0-edge.6
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/dist/custom/MJActionEntityServer.server.d.ts +2 -2
- package/dist/custom/MJActionEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJActionEntityServer.server.js +8 -6
- package/dist/custom/MJActionEntityServer.server.js.map +1 -1
- package/dist/custom/MJApplicationEntityServer.server.d.ts +2 -2
- package/dist/custom/MJApplicationEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJApplicationEntityServer.server.js +4 -4
- package/dist/custom/MJApplicationEntityServer.server.js.map +1 -1
- package/dist/custom/MJRemoteOperationEntityServer.server.d.ts +2 -2
- package/dist/custom/MJRemoteOperationEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJRemoteOperationEntityServer.server.js +6 -4
- package/dist/custom/MJRemoteOperationEntityServer.server.js.map +1 -1
- package/dist/custom/MJRoleEntityServer.server.d.ts +78 -0
- package/dist/custom/MJRoleEntityServer.server.d.ts.map +1 -0
- package/dist/custom/MJRoleEntityServer.server.js +131 -0
- package/dist/custom/MJRoleEntityServer.server.js.map +1 -0
- package/dist/custom/MJUserEntityServer.server.d.ts +243 -0
- package/dist/custom/MJUserEntityServer.server.d.ts.map +1 -0
- package/dist/custom/MJUserEntityServer.server.js +335 -0
- package/dist/custom/MJUserEntityServer.server.js.map +1 -0
- package/dist/custom/MJUserRoleEntityServer.server.d.ts +146 -0
- package/dist/custom/MJUserRoleEntityServer.server.d.ts.map +1 -0
- package/dist/custom/MJUserRoleEntityServer.server.js +216 -0
- package/dist/custom/MJUserRoleEntityServer.server.js.map +1 -0
- package/dist/custom/MJUserViewEntityServer.server.d.ts +12 -1
- package/dist/custom/MJUserViewEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJUserViewEntityServer.server.js +33 -1
- package/dist/custom/MJUserViewEntityServer.server.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +28 -28
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { EntityDeleteOptions, EntitySaveOptions, ValidationResult } from '@memberjunction/core';
|
|
2
|
+
import { MJUserEntity } from '@memberjunction/core-entities';
|
|
3
|
+
/**
|
|
4
|
+
* Server-side `MJ: Users` entity enforcing MJ's privilege-elevation invariant (issue #4260).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS AT ALL. `User.Type` is the column every Owner check in the platform reads —
|
|
7
|
+
* `SqlLoggingConfigResolver`, magic-link `canIssueInvites`, the backup-system-user fallback in
|
|
8
|
+
* MJServer's bootstrap. Writing it is therefore equivalent to granting yourself the platform's
|
|
9
|
+
* superuser level. Nothing below this class stops that: on the baseline seed the `Developer` and
|
|
10
|
+
* `Integration` roles hold unfiltered `CanCreate`/`CanUpdate`/`CanDelete` on `MJ: Users` (verified
|
|
11
|
+
* against a live database, not just the seed), `AllowUpdateAPI`/`AllowDeleteAPI` are true on the
|
|
12
|
+
* entity, `AllowUpdateAPI` is also true on both the `Type` and `Name` fields, `Name` has no unique
|
|
13
|
+
* index, and CodeGen has already issued `GRANT EXECUTE` on the relevant stored procedures to those
|
|
14
|
+
* roles' DB roles.
|
|
15
|
+
*
|
|
16
|
+
* WHY NOT ROW-LEVEL SECURITY. MJ's only field-adjacent access control is `RowLevelSecurityFilter`,
|
|
17
|
+
* and an "own row only" update filter does NOT close this: scoping the update to the caller's own
|
|
18
|
+
* row still permits setting one's OWN `Type` to `'Owner'`. There is no per-role FIELD permission in
|
|
19
|
+
* MJ, and setting `EntityField.AllowUpdateAPI = 0` on `Type` would block Owners too, breaking every
|
|
20
|
+
* legitimate admin path. An invariant in the save/delete path is the only mechanism that expresses
|
|
21
|
+
* the actual rule.
|
|
22
|
+
*
|
|
23
|
+
* WHY HERE RATHER THAN IN A RESOLVER. `Validate()` runs inside `BaseEntity.Save()` and this class
|
|
24
|
+
* also overrides `Save()` and `Delete()`, so all five hold on every write path — GraphQL resolvers,
|
|
25
|
+
* Remote Operations, the Create/Update/Delete Record actions, metadata sync, one-off scripts — and
|
|
26
|
+
* for any role a deployment invents, not just the two seeded ones. A resolver-level check would
|
|
27
|
+
* cover one door in a building with several.
|
|
28
|
+
*
|
|
29
|
+
* The `Save()` override is what makes that sentence literally true rather than nearly true.
|
|
30
|
+
* `Validate()` alone does NOT cover every write path: `BaseEntity.Save()` force-passes validation
|
|
31
|
+
* without calling `Validate()` when `EntitySaveOptions.ReplayOnly` is set (`baseEntity.ts:3725`),
|
|
32
|
+
* and `ReplayOnly` does not suppress the write. Invariants 1-4 were therefore skippable by an
|
|
33
|
+
* option, while invariant 5 was not — `Delete()` being an override. See `Save()` below for the
|
|
34
|
+
* reachability analysis and why the fix refuses rather than re-validates.
|
|
35
|
+
*
|
|
36
|
+
* THE INVARIANTS, for a caller whose `Type` is not `'Owner'`:
|
|
37
|
+
*
|
|
38
|
+
* 1. **Creating a `MJ: Users` row at all is refused.** This is stricter than "may not create an
|
|
39
|
+
* Owner": there are FOUR automated creators of this entity — `NewUserBase.createNewUser`
|
|
40
|
+
* (`MJServer/src/auth/newUsers.ts`), `MagicLinkService`'s provisioning path
|
|
41
|
+
* (`MJServer/src/auth/magicLink/MagicLinkService.ts`), and `CreateNewUserBase.createNewUser`
|
|
42
|
+
* (`CodeGenLib/src/Misc/createNewUser.ts:33`, a CLI provisioning tool that sets `Type='Owner'`
|
|
43
|
+
* unconditionally at `:39` — already refused for a non-Owner caller before this round,
|
|
44
|
+
* regardless of the analysis below). The first two run as `ResolveConfiguredPrincipal(...)`,
|
|
45
|
+
* whose ladder is: rung 1 matches the configured string against `User.Name`, rung 2 against
|
|
46
|
+
* `User.Email` — NEITHER rung filters by `Type` (`MJServer/src/auth/principals.ts:134,141`) —
|
|
47
|
+
* and only rungs 3-4 (System-by-ID, then lowest-ID-among-ACTIVE-Owners) guarantee an Owner.
|
|
48
|
+
* So "no legitimate path creates a user row as a non-Owner" is NOT unconditional: it holds
|
|
49
|
+
* only while a deployment's `contextUserForNewUserCreation` / `contextUserForProvisioning`
|
|
50
|
+
* names an Owner-type user's `Name` or `Email` (the shipped default, `not.set@nowhere.com`,
|
|
51
|
+
* resolves by Email to the seeded Owner, so default installs are unaffected). A deployment
|
|
52
|
+
* that instead points either setting at a non-Owner user will have JWT auto-provisioning and
|
|
53
|
+
* magic-link provisioning fail CLOSED at `Save()` after this change — loudly, not silently —
|
|
54
|
+
* rather than continuing to create rows as that non-Owner; see the changeset for the upgrade
|
|
55
|
+
* note. Separately, Explorer's user-management UI has NO Owner gate today (no such guard
|
|
56
|
+
* exists in `user-management.component.ts` or its module), so a Developer-role non-Owner
|
|
57
|
+
* reaches it in practice and will now receive this same create/delete refusal there — a real
|
|
58
|
+
* consequence for such deployments, not a hypothetical one.
|
|
59
|
+
* A FOURTH creator exists and is not config-driven: `SyncRolesUsersResolver.AddNewUsers`
|
|
60
|
+
* (`MJServer/src/resolvers/SyncRolesUsersResolver.ts:343`), whose sibling `UpdateExistingUsers`
|
|
61
|
+
* also sets `Name` AND `Type` unconditionally on every synced row and whose `DeleteSingleUser`
|
|
62
|
+
* deletes. All three carry `@RequireSystemUser()`, and `getSystemUser()` resolves the seeded
|
|
63
|
+
* `Type='Owner'` system user, so the guard exempts them on a default install — but a deployment
|
|
64
|
+
* whose system user is NOT an Owner will see that sync path fail closed too, for the same
|
|
65
|
+
* reason as the config-driven pair above. Note also that `DeleteSingleUser` reads a `false`
|
|
66
|
+
* from `Delete()` as an FK-constraint condition and downgrades to a soft delete; invariant 5
|
|
67
|
+
* gives that same `false` a second meaning (non-Owner caller), which that call site does not
|
|
68
|
+
* distinguish.
|
|
69
|
+
* Refusing only `Type='Owner'` on create is NOT enough on its own: `Name` has no unique index
|
|
70
|
+
* and both seeded non-Owner roles hold `CanCreate`, so a non-Owner could otherwise repeatedly
|
|
71
|
+
* `Create` rows named to match the configured principal string until one sorts below the real
|
|
72
|
+
* system user by ID — the exact principal-redirection invariant 4 (below) exists to prevent,
|
|
73
|
+
* just reached through INSERT instead of UPDATE.
|
|
74
|
+
* 2. `Type` may not be changed on an EXISTING row. It is a two-value CHECK column
|
|
75
|
+
* (`'User' | 'Owner'`), so any change by a non-Owner is either self-promotion or demoting
|
|
76
|
+
* somebody else. (Creation is already covered by invariant 1, so this only needs to consider
|
|
77
|
+
* updates.)
|
|
78
|
+
* 3. The row must be the caller's own, compared against the PRE-SAVE `ID`. `ID` is a primary-key
|
|
79
|
+
* field — `EntityFieldInfo.ReadOnly` is `true` for `IsPrimaryKey`, and `EntityField.Value`'s
|
|
80
|
+
* setter silently ignores writes to a `ReadOnly` field after the record's initial hydration —
|
|
81
|
+
* so on a genuinely LOADED row `this.ID` is not reassignable through ordinary means. The
|
|
82
|
+
* pre-save value still matters for a narrower reason: it ties this guard to the row identity
|
|
83
|
+
* established the last time the object was actually loaded/hydrated from the database, which
|
|
84
|
+
* is what `ResolverBase.UpdateRecord` does first for any entity with `TrackRecordChanges=1`
|
|
85
|
+
* (as `MJ: Users` has) — rather than trusting whatever the in-memory object merely arrived
|
|
86
|
+
* holding. If that pre-save identity cannot be established at all, the guard fails CLOSED
|
|
87
|
+
* (refuses the save) instead of silently permitting it — see `validateOwnRowOnly`.
|
|
88
|
+
* 4. `Name` may not be changed on an existing row. Invariants 2-3 do NOT cover this — renaming
|
|
89
|
+
* yourself is editing your own row. `resolvePrincipalFrom` (MJServer `auth/principals.ts`)
|
|
90
|
+
* resolves `contextUserForNewUserCreation` / `contextUserForProvisioning` /
|
|
91
|
+
* `contextUserForLookup` against `User.Name` FIRST, breaking ties by lowest ID, so a user
|
|
92
|
+
* who can rename themselves to the configured string and who sorts below the real system
|
|
93
|
+
* user becomes the principal the server acts as. That ordering is deliberate (backward
|
|
94
|
+
* compatibility) and is only sound while `Name` is not writable by untrusted parties; this
|
|
95
|
+
* invariant is what makes that true. `Name` is the login identifier — auto-provisioning sets
|
|
96
|
+
* `Name = email` — while `FirstName` / `LastName` / `Title` are the display fields and stay
|
|
97
|
+
* freely editable.
|
|
98
|
+
* `Email` is the ladder's OTHER rung (`principals.ts:141`) with the IDENTICAL lack of a `Type`
|
|
99
|
+
* filter, and is deliberately left mutable here — not overlooked. Two things narrow it: (a)
|
|
100
|
+
* `MJ: Users.Email` carries the database's `UQ_User_Email` unique constraint, so redirecting
|
|
101
|
+
* the Email rung to your own row requires the configured candidate to match NO active user at
|
|
102
|
+
* all — already the misconfiguration case this file's docs (and `resolvePrincipalFrom`'s own
|
|
103
|
+
* per-redeem logging) already surface loudly, not a quiet success path; and (b) even granting
|
|
104
|
+
* that misconfiguration, invariants 1 and 3 mean the payoff is no longer elevation: whatever
|
|
105
|
+
* row a provisioning path resolves its principal to, THIS guard still checks that resolved
|
|
106
|
+
* principal's actual `Type` before permitting the write it is attempting, so a non-Owner who
|
|
107
|
+
* gets themselves matched by the Email rung still cannot create or promote anything through
|
|
108
|
+
* it — the provisioning operation that would have run as them instead fails CLOSED. Freezing
|
|
109
|
+
* `Email` too would add friction to an already-narrow, already-loud misconfiguration path
|
|
110
|
+
* without closing any route that is still open.
|
|
111
|
+
* 5. **Deleting a `MJ: Users` row at all is refused** (see the `Delete()` override below). MJ
|
|
112
|
+
* deactivates users via `IsActive`; it does not delete them. An unguarded delete would let a
|
|
113
|
+
* non-Owner remove ANY account — Owners included, which destroys the very accounts every
|
|
114
|
+
* exemption above depends on.
|
|
115
|
+
*
|
|
116
|
+
* Owner-type callers are exempt from all five — CONDITIONALLY on the deployment's own configuration
|
|
117
|
+
* keeping `contextUserForNewUserCreation` / `contextUserForProvisioning` pointed at an Owner (see
|
|
118
|
+
* invariant 1 above for why that is not automatic). Provided it is, this keeps admin user management
|
|
119
|
+
* working, and it keeps auto-provisioning working — `NewUserBase.createNewUser` runs as
|
|
120
|
+
* `contextUserForNewUserCreation`, which resolves to the seeded system user (`Type='Owner'`) under
|
|
121
|
+
* the shipped default.
|
|
122
|
+
* A caller-less save (no `ActiveUser` at all — e.g. a system/CLI path running under a bound provider
|
|
123
|
+
* default) is likewise treated as exempt, though for `Save()` this is effectively decorative:
|
|
124
|
+
* `BaseEntity.CheckPermissions` already throws on a falsy `ActiveUser` and runs BEFORE `Validate()`
|
|
125
|
+
* inside `Save()`, so in production a caller-less `Save()` never reaches this guard's `Validate()`
|
|
126
|
+
* body at all — see the "no caller" test for the exact call ordering. `Delete()`'s sequencing is the
|
|
127
|
+
* OPPOSITE and the "no caller ⇒ exempt" default IS load-bearing there — see `callerIsOwner()`'s
|
|
128
|
+
* docstring.
|
|
129
|
+
*
|
|
130
|
+
* Pure: reads only this record's own field state and the caller. No `RunView`, no provider, no
|
|
131
|
+
* engine, no I/O — so it costs nothing per save/delete and is unit-testable without a database.
|
|
132
|
+
*/
|
|
133
|
+
export declare class MJUserEntityServer extends MJUserEntity {
|
|
134
|
+
Validate(): ValidationResult;
|
|
135
|
+
/**
|
|
136
|
+
* Refuses a `ReplayOnly` save by a non-Owner caller.
|
|
137
|
+
*
|
|
138
|
+
* WHY THIS OVERRIDE EXISTS. Invariants 1-4 are enforced in `Validate()`, and `Validate()` is the
|
|
139
|
+
* one enforcement point `BaseEntity.Save()` can be told to skip: under `EntitySaveOptions.ReplayOnly`
|
|
140
|
+
* it force-passes validation WITHOUT calling `Validate()` at all (`baseEntity.ts:3725`), and
|
|
141
|
+
* `ReplayOnly` does NOT suppress the write — the provider only uses it to bypass the
|
|
142
|
+
* `AllowUpdateAPI`/`AllowCreateAPI` gates before building and executing the SQL
|
|
143
|
+
* (`databaseProviderBase.ts:1436-1443`). So a `ReplayOnly` save skipped all four Save-side
|
|
144
|
+
* invariants while invariant 5 stayed enforced, because `Delete()` is an override and an
|
|
145
|
+
* override cannot be switched off. This restores the symmetry: now neither half depends on the
|
|
146
|
+
* caller's options.
|
|
147
|
+
*
|
|
148
|
+
* NOT CURRENTLY REACHABLE BY AN UNTRUSTED CALLER, and this is deliberately belt-and-braces
|
|
149
|
+
* rather than a live hole. Every wire path that accepts `ReplayOnly` was enumerated: the
|
|
150
|
+
* GraphQL `options___`/`DeleteOptionsInput` input exists only on the DELETE mutation (create and
|
|
151
|
+
* update carry no options input, and `ResolverBase.CreateRecord`/`UpdateRecord` call `Save()`
|
|
152
|
+
* with none); REST's `EntityCRUDHandler` does accept it, but calls `entity.Validate()`
|
|
153
|
+
* explicitly before `Save()`, so the guard still runs there; and `graphQLSystemUserClient`
|
|
154
|
+
* requires the system API key, i.e. a caller who is already superuser. The point is that the
|
|
155
|
+
* class docstring's "holds on EVERY write path" is a promise a future wire path forwarding save
|
|
156
|
+
* options would otherwise quietly break — a guard whose protection is one option away from off
|
|
157
|
+
* is not the guard this file claims to be.
|
|
158
|
+
*
|
|
159
|
+
* WHY REFUSE RATHER THAN RE-RUN THE INVARIANTS. Re-running invariants 1-4 here would duplicate
|
|
160
|
+
* `Validate()`'s logic in a second place that must then be kept in step with it — the exact
|
|
161
|
+
* duplicated-decision this repo's design rules call out. Refusing outright is smaller and
|
|
162
|
+
* strictly safer: `ReplayOnly` is a replication/replay facility for trusted sync paths, and a
|
|
163
|
+
* non-Owner has no legitimate reason to replay writes onto the user table. Owners are exempt,
|
|
164
|
+
* so replication and admin paths that run as an Owner are unaffected.
|
|
165
|
+
*/
|
|
166
|
+
Save(options?: EntitySaveOptions): Promise<boolean>;
|
|
167
|
+
private refuseSave;
|
|
168
|
+
/**
|
|
169
|
+
* Refuses deletion of any `MJ: Users` row by a non-Owner caller. See invariant 5 above for why:
|
|
170
|
+
* MJ deactivates users via `IsActive` rather than deleting them, and an unguarded delete would
|
|
171
|
+
* let a non-Owner remove any account, including Owner accounts this class's other exemptions
|
|
172
|
+
* depend on.
|
|
173
|
+
*
|
|
174
|
+
* Reports the refusal the way `MJListEntityServer.Delete` does for its own row-level DELETE
|
|
175
|
+
* authorization check — a `BaseEntityResult` on the result history so `LatestResult.CompleteMessage`
|
|
176
|
+
* carries the reason (`Delete()` returns `false` on a logical rejection rather than throwing; see
|
|
177
|
+
* the CLAUDE.md Save/Delete error-handling contract) — rather than `MJUserRoutineEntityServer`'s
|
|
178
|
+
* `Delete()` override, which is FK-cleanup ordering, not an authorization decision, and reports
|
|
179
|
+
* nothing beyond a bare `false`.
|
|
180
|
+
*/
|
|
181
|
+
Delete(options?: EntityDeleteOptions): Promise<boolean>;
|
|
182
|
+
private refuseDelete;
|
|
183
|
+
/**
|
|
184
|
+
* Invariant 1 — a non-Owner may not create a `MJ: Users` row at all. See the class docstring
|
|
185
|
+
* for why this is the correct scope (not merely "may not create an Owner"): `Name` has no
|
|
186
|
+
* unique index, so restricting only `Type='Owner'` on create would leave the INSERT path open
|
|
187
|
+
* to the same principal-redirection invariant 4 blocks on UPDATE.
|
|
188
|
+
*/
|
|
189
|
+
private validateCreateRefused;
|
|
190
|
+
/**
|
|
191
|
+
* Invariant 2 — a non-Owner may not change `Type` on an EXISTING row (self-promotion, or
|
|
192
|
+
* demoting somebody else). Non-Owner creation is refused entirely by `validateCreateRefused`,
|
|
193
|
+
* so this method only needs to consider updates — `Validate()` only calls it on that branch.
|
|
194
|
+
*/
|
|
195
|
+
private validateNoTypeChange;
|
|
196
|
+
/**
|
|
197
|
+
* Invariant 3 — a non-Owner may only modify their own user row, compared against the PRE-SAVE
|
|
198
|
+
* `ID` (see the class docstring for why the pre-save value is the meaningful comparison here).
|
|
199
|
+
*
|
|
200
|
+
* Fails CLOSED: if the pre-save identity cannot be established at all, the save is refused
|
|
201
|
+
* rather than silently permitted. This is currently unreachable in production only because
|
|
202
|
+
* `MJ: Users` has `TrackRecordChanges=1`, which forces `ResolverBase.UpdateRecord` to genuinely
|
|
203
|
+
* load the row from the database before applying edits — but that is an unrelated entity flag,
|
|
204
|
+
* not a guarantee this class should assume will always hold.
|
|
205
|
+
*/
|
|
206
|
+
private validateOwnRowOnly;
|
|
207
|
+
/**
|
|
208
|
+
* Invariant 4 — a non-Owner may not change `Name` on an existing row.
|
|
209
|
+
*
|
|
210
|
+
* `Name` is the column the context-user ladder resolves against first, so it is effectively a
|
|
211
|
+
* capability name, not a display name. Non-Owner creation (where auto-provisioning legitimately
|
|
212
|
+
* sets `Name = email`) is already refused entirely by `validateCreateRefused`, so this method
|
|
213
|
+
* only runs on updates.
|
|
214
|
+
*
|
|
215
|
+
* `Email` is deliberately NOT frozen alongside `Name` — see the class docstring's invariant 4
|
|
216
|
+
* paragraph for why the ladder's other rung doesn't need the same treatment.
|
|
217
|
+
*/
|
|
218
|
+
private validateNameImmutable;
|
|
219
|
+
/**
|
|
220
|
+
* True when the caller is an Owner (or when there is no caller to evaluate — the guard has
|
|
221
|
+
* nothing to compare against).
|
|
222
|
+
*
|
|
223
|
+
* The "no caller ⇒ exempt" default has DIFFERENT reachability for `Save()` vs. `Delete()`:
|
|
224
|
+
* - `Save()`: `BaseEntity.CheckPermissions` throws on a falsy `ActiveUser`
|
|
225
|
+
* (`baseEntity.ts:4003-4005`) and runs at `baseEntity.ts:3702`, BEFORE `Validate()` is
|
|
226
|
+
* called at `baseEntity.ts:3730` — so a caller-less `Save()` never reaches this method at
|
|
227
|
+
* all in production. The default is effectively decorative there.
|
|
228
|
+
* - `Delete()`: the sequencing is the OPPOSITE. THIS class's `Delete()` override calls
|
|
229
|
+
* `callerIsOwner()` as its very first statement, before `super.Delete()` is ever invoked —
|
|
230
|
+
* `CheckPermissions` only runs later, INSIDE `super.Delete()` (`baseEntity.ts:4612`). So a
|
|
231
|
+
* caller-less `Delete()` call DOES reach this method first, and the "no caller ⇒ exempt"
|
|
232
|
+
* default here is load-bearing: it lets the call proceed into `super.Delete()`, where
|
|
233
|
+
* `CheckPermissions` is the thing that actually refuses it. If this default were flipped to
|
|
234
|
+
* "no caller ⇒ refuse", a caller-less delete would be refused by `refuseDelete()` instead —
|
|
235
|
+
* same ultimate outcome (refused), different refusal mechanism and message.
|
|
236
|
+
*
|
|
237
|
+
* `Type` is an `NCHAR` column, so it arrives space-padded; casing is normalized for the same
|
|
238
|
+
* reason `principals.ts` does. Reads `ActiveUser` rather than `ContextCurrentUser` directly so
|
|
239
|
+
* a per-request provider's `CurrentUser` is honored on multi-provider servers.
|
|
240
|
+
*/
|
|
241
|
+
private callerIsOwner;
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=MJUserEntityServer.server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MJUserEntityServer.server.d.ts","sourceRoot":"","sources":["../../src/custom/MJUserEntityServer.server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,mBAAmB,EAAE,iBAAiB,EAA4C,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExK,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiIG;AACH,qBACa,kBAAmB,SAAQ,YAAY;IAChC,QAAQ,IAAI,gBAAgB;IAe5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACmB,IAAI,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC;IAOzE,OAAO,CAAC,UAAU;IAalB;;;;;;;;;;;;OAYG;IACmB,MAAM,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,CAAC;IAO7E,OAAO,CAAC,YAAY;IAYpB;;;;;OAKG;IACH,OAAO,CAAC,qBAAqB;IAc7B;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAa5B;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB;IAsB1B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,qBAAqB;IAc7B;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,OAAO,CAAC,aAAa;CAOxB"}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { BaseEntity, BaseEntityResult, ValidationErrorInfo, ValidationErrorType } from '@memberjunction/core';
|
|
8
|
+
import { RegisterClass, UUIDsEqual } from '@memberjunction/global';
|
|
9
|
+
import { MJUserEntity } from '@memberjunction/core-entities';
|
|
10
|
+
/**
|
|
11
|
+
* Server-side `MJ: Users` entity enforcing MJ's privilege-elevation invariant (issue #4260).
|
|
12
|
+
*
|
|
13
|
+
* WHY THIS EXISTS AT ALL. `User.Type` is the column every Owner check in the platform reads —
|
|
14
|
+
* `SqlLoggingConfigResolver`, magic-link `canIssueInvites`, the backup-system-user fallback in
|
|
15
|
+
* MJServer's bootstrap. Writing it is therefore equivalent to granting yourself the platform's
|
|
16
|
+
* superuser level. Nothing below this class stops that: on the baseline seed the `Developer` and
|
|
17
|
+
* `Integration` roles hold unfiltered `CanCreate`/`CanUpdate`/`CanDelete` on `MJ: Users` (verified
|
|
18
|
+
* against a live database, not just the seed), `AllowUpdateAPI`/`AllowDeleteAPI` are true on the
|
|
19
|
+
* entity, `AllowUpdateAPI` is also true on both the `Type` and `Name` fields, `Name` has no unique
|
|
20
|
+
* index, and CodeGen has already issued `GRANT EXECUTE` on the relevant stored procedures to those
|
|
21
|
+
* roles' DB roles.
|
|
22
|
+
*
|
|
23
|
+
* WHY NOT ROW-LEVEL SECURITY. MJ's only field-adjacent access control is `RowLevelSecurityFilter`,
|
|
24
|
+
* and an "own row only" update filter does NOT close this: scoping the update to the caller's own
|
|
25
|
+
* row still permits setting one's OWN `Type` to `'Owner'`. There is no per-role FIELD permission in
|
|
26
|
+
* MJ, and setting `EntityField.AllowUpdateAPI = 0` on `Type` would block Owners too, breaking every
|
|
27
|
+
* legitimate admin path. An invariant in the save/delete path is the only mechanism that expresses
|
|
28
|
+
* the actual rule.
|
|
29
|
+
*
|
|
30
|
+
* WHY HERE RATHER THAN IN A RESOLVER. `Validate()` runs inside `BaseEntity.Save()` and this class
|
|
31
|
+
* also overrides `Save()` and `Delete()`, so all five hold on every write path — GraphQL resolvers,
|
|
32
|
+
* Remote Operations, the Create/Update/Delete Record actions, metadata sync, one-off scripts — and
|
|
33
|
+
* for any role a deployment invents, not just the two seeded ones. A resolver-level check would
|
|
34
|
+
* cover one door in a building with several.
|
|
35
|
+
*
|
|
36
|
+
* The `Save()` override is what makes that sentence literally true rather than nearly true.
|
|
37
|
+
* `Validate()` alone does NOT cover every write path: `BaseEntity.Save()` force-passes validation
|
|
38
|
+
* without calling `Validate()` when `EntitySaveOptions.ReplayOnly` is set (`baseEntity.ts:3725`),
|
|
39
|
+
* and `ReplayOnly` does not suppress the write. Invariants 1-4 were therefore skippable by an
|
|
40
|
+
* option, while invariant 5 was not — `Delete()` being an override. See `Save()` below for the
|
|
41
|
+
* reachability analysis and why the fix refuses rather than re-validates.
|
|
42
|
+
*
|
|
43
|
+
* THE INVARIANTS, for a caller whose `Type` is not `'Owner'`:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Creating a `MJ: Users` row at all is refused.** This is stricter than "may not create an
|
|
46
|
+
* Owner": there are FOUR automated creators of this entity — `NewUserBase.createNewUser`
|
|
47
|
+
* (`MJServer/src/auth/newUsers.ts`), `MagicLinkService`'s provisioning path
|
|
48
|
+
* (`MJServer/src/auth/magicLink/MagicLinkService.ts`), and `CreateNewUserBase.createNewUser`
|
|
49
|
+
* (`CodeGenLib/src/Misc/createNewUser.ts:33`, a CLI provisioning tool that sets `Type='Owner'`
|
|
50
|
+
* unconditionally at `:39` — already refused for a non-Owner caller before this round,
|
|
51
|
+
* regardless of the analysis below). The first two run as `ResolveConfiguredPrincipal(...)`,
|
|
52
|
+
* whose ladder is: rung 1 matches the configured string against `User.Name`, rung 2 against
|
|
53
|
+
* `User.Email` — NEITHER rung filters by `Type` (`MJServer/src/auth/principals.ts:134,141`) —
|
|
54
|
+
* and only rungs 3-4 (System-by-ID, then lowest-ID-among-ACTIVE-Owners) guarantee an Owner.
|
|
55
|
+
* So "no legitimate path creates a user row as a non-Owner" is NOT unconditional: it holds
|
|
56
|
+
* only while a deployment's `contextUserForNewUserCreation` / `contextUserForProvisioning`
|
|
57
|
+
* names an Owner-type user's `Name` or `Email` (the shipped default, `not.set@nowhere.com`,
|
|
58
|
+
* resolves by Email to the seeded Owner, so default installs are unaffected). A deployment
|
|
59
|
+
* that instead points either setting at a non-Owner user will have JWT auto-provisioning and
|
|
60
|
+
* magic-link provisioning fail CLOSED at `Save()` after this change — loudly, not silently —
|
|
61
|
+
* rather than continuing to create rows as that non-Owner; see the changeset for the upgrade
|
|
62
|
+
* note. Separately, Explorer's user-management UI has NO Owner gate today (no such guard
|
|
63
|
+
* exists in `user-management.component.ts` or its module), so a Developer-role non-Owner
|
|
64
|
+
* reaches it in practice and will now receive this same create/delete refusal there — a real
|
|
65
|
+
* consequence for such deployments, not a hypothetical one.
|
|
66
|
+
* A FOURTH creator exists and is not config-driven: `SyncRolesUsersResolver.AddNewUsers`
|
|
67
|
+
* (`MJServer/src/resolvers/SyncRolesUsersResolver.ts:343`), whose sibling `UpdateExistingUsers`
|
|
68
|
+
* also sets `Name` AND `Type` unconditionally on every synced row and whose `DeleteSingleUser`
|
|
69
|
+
* deletes. All three carry `@RequireSystemUser()`, and `getSystemUser()` resolves the seeded
|
|
70
|
+
* `Type='Owner'` system user, so the guard exempts them on a default install — but a deployment
|
|
71
|
+
* whose system user is NOT an Owner will see that sync path fail closed too, for the same
|
|
72
|
+
* reason as the config-driven pair above. Note also that `DeleteSingleUser` reads a `false`
|
|
73
|
+
* from `Delete()` as an FK-constraint condition and downgrades to a soft delete; invariant 5
|
|
74
|
+
* gives that same `false` a second meaning (non-Owner caller), which that call site does not
|
|
75
|
+
* distinguish.
|
|
76
|
+
* Refusing only `Type='Owner'` on create is NOT enough on its own: `Name` has no unique index
|
|
77
|
+
* and both seeded non-Owner roles hold `CanCreate`, so a non-Owner could otherwise repeatedly
|
|
78
|
+
* `Create` rows named to match the configured principal string until one sorts below the real
|
|
79
|
+
* system user by ID — the exact principal-redirection invariant 4 (below) exists to prevent,
|
|
80
|
+
* just reached through INSERT instead of UPDATE.
|
|
81
|
+
* 2. `Type` may not be changed on an EXISTING row. It is a two-value CHECK column
|
|
82
|
+
* (`'User' | 'Owner'`), so any change by a non-Owner is either self-promotion or demoting
|
|
83
|
+
* somebody else. (Creation is already covered by invariant 1, so this only needs to consider
|
|
84
|
+
* updates.)
|
|
85
|
+
* 3. The row must be the caller's own, compared against the PRE-SAVE `ID`. `ID` is a primary-key
|
|
86
|
+
* field — `EntityFieldInfo.ReadOnly` is `true` for `IsPrimaryKey`, and `EntityField.Value`'s
|
|
87
|
+
* setter silently ignores writes to a `ReadOnly` field after the record's initial hydration —
|
|
88
|
+
* so on a genuinely LOADED row `this.ID` is not reassignable through ordinary means. The
|
|
89
|
+
* pre-save value still matters for a narrower reason: it ties this guard to the row identity
|
|
90
|
+
* established the last time the object was actually loaded/hydrated from the database, which
|
|
91
|
+
* is what `ResolverBase.UpdateRecord` does first for any entity with `TrackRecordChanges=1`
|
|
92
|
+
* (as `MJ: Users` has) — rather than trusting whatever the in-memory object merely arrived
|
|
93
|
+
* holding. If that pre-save identity cannot be established at all, the guard fails CLOSED
|
|
94
|
+
* (refuses the save) instead of silently permitting it — see `validateOwnRowOnly`.
|
|
95
|
+
* 4. `Name` may not be changed on an existing row. Invariants 2-3 do NOT cover this — renaming
|
|
96
|
+
* yourself is editing your own row. `resolvePrincipalFrom` (MJServer `auth/principals.ts`)
|
|
97
|
+
* resolves `contextUserForNewUserCreation` / `contextUserForProvisioning` /
|
|
98
|
+
* `contextUserForLookup` against `User.Name` FIRST, breaking ties by lowest ID, so a user
|
|
99
|
+
* who can rename themselves to the configured string and who sorts below the real system
|
|
100
|
+
* user becomes the principal the server acts as. That ordering is deliberate (backward
|
|
101
|
+
* compatibility) and is only sound while `Name` is not writable by untrusted parties; this
|
|
102
|
+
* invariant is what makes that true. `Name` is the login identifier — auto-provisioning sets
|
|
103
|
+
* `Name = email` — while `FirstName` / `LastName` / `Title` are the display fields and stay
|
|
104
|
+
* freely editable.
|
|
105
|
+
* `Email` is the ladder's OTHER rung (`principals.ts:141`) with the IDENTICAL lack of a `Type`
|
|
106
|
+
* filter, and is deliberately left mutable here — not overlooked. Two things narrow it: (a)
|
|
107
|
+
* `MJ: Users.Email` carries the database's `UQ_User_Email` unique constraint, so redirecting
|
|
108
|
+
* the Email rung to your own row requires the configured candidate to match NO active user at
|
|
109
|
+
* all — already the misconfiguration case this file's docs (and `resolvePrincipalFrom`'s own
|
|
110
|
+
* per-redeem logging) already surface loudly, not a quiet success path; and (b) even granting
|
|
111
|
+
* that misconfiguration, invariants 1 and 3 mean the payoff is no longer elevation: whatever
|
|
112
|
+
* row a provisioning path resolves its principal to, THIS guard still checks that resolved
|
|
113
|
+
* principal's actual `Type` before permitting the write it is attempting, so a non-Owner who
|
|
114
|
+
* gets themselves matched by the Email rung still cannot create or promote anything through
|
|
115
|
+
* it — the provisioning operation that would have run as them instead fails CLOSED. Freezing
|
|
116
|
+
* `Email` too would add friction to an already-narrow, already-loud misconfiguration path
|
|
117
|
+
* without closing any route that is still open.
|
|
118
|
+
* 5. **Deleting a `MJ: Users` row at all is refused** (see the `Delete()` override below). MJ
|
|
119
|
+
* deactivates users via `IsActive`; it does not delete them. An unguarded delete would let a
|
|
120
|
+
* non-Owner remove ANY account — Owners included, which destroys the very accounts every
|
|
121
|
+
* exemption above depends on.
|
|
122
|
+
*
|
|
123
|
+
* Owner-type callers are exempt from all five — CONDITIONALLY on the deployment's own configuration
|
|
124
|
+
* keeping `contextUserForNewUserCreation` / `contextUserForProvisioning` pointed at an Owner (see
|
|
125
|
+
* invariant 1 above for why that is not automatic). Provided it is, this keeps admin user management
|
|
126
|
+
* working, and it keeps auto-provisioning working — `NewUserBase.createNewUser` runs as
|
|
127
|
+
* `contextUserForNewUserCreation`, which resolves to the seeded system user (`Type='Owner'`) under
|
|
128
|
+
* the shipped default.
|
|
129
|
+
* A caller-less save (no `ActiveUser` at all — e.g. a system/CLI path running under a bound provider
|
|
130
|
+
* default) is likewise treated as exempt, though for `Save()` this is effectively decorative:
|
|
131
|
+
* `BaseEntity.CheckPermissions` already throws on a falsy `ActiveUser` and runs BEFORE `Validate()`
|
|
132
|
+
* inside `Save()`, so in production a caller-less `Save()` never reaches this guard's `Validate()`
|
|
133
|
+
* body at all — see the "no caller" test for the exact call ordering. `Delete()`'s sequencing is the
|
|
134
|
+
* OPPOSITE and the "no caller ⇒ exempt" default IS load-bearing there — see `callerIsOwner()`'s
|
|
135
|
+
* docstring.
|
|
136
|
+
*
|
|
137
|
+
* Pure: reads only this record's own field state and the caller. No `RunView`, no provider, no
|
|
138
|
+
* engine, no I/O — so it costs nothing per save/delete and is unit-testable without a database.
|
|
139
|
+
*/
|
|
140
|
+
let MJUserEntityServer = class MJUserEntityServer extends MJUserEntity {
|
|
141
|
+
Validate() {
|
|
142
|
+
const result = super.Validate();
|
|
143
|
+
if (!this.callerIsOwner()) {
|
|
144
|
+
if (!this.IsSaved) {
|
|
145
|
+
this.validateCreateRefused(result);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.validateNoTypeChange(result);
|
|
149
|
+
this.validateOwnRowOnly(result);
|
|
150
|
+
this.validateNameImmutable(result);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
result.Success = result.Success && result.Errors.length === 0;
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Refuses a `ReplayOnly` save by a non-Owner caller.
|
|
158
|
+
*
|
|
159
|
+
* WHY THIS OVERRIDE EXISTS. Invariants 1-4 are enforced in `Validate()`, and `Validate()` is the
|
|
160
|
+
* one enforcement point `BaseEntity.Save()` can be told to skip: under `EntitySaveOptions.ReplayOnly`
|
|
161
|
+
* it force-passes validation WITHOUT calling `Validate()` at all (`baseEntity.ts:3725`), and
|
|
162
|
+
* `ReplayOnly` does NOT suppress the write — the provider only uses it to bypass the
|
|
163
|
+
* `AllowUpdateAPI`/`AllowCreateAPI` gates before building and executing the SQL
|
|
164
|
+
* (`databaseProviderBase.ts:1436-1443`). So a `ReplayOnly` save skipped all four Save-side
|
|
165
|
+
* invariants while invariant 5 stayed enforced, because `Delete()` is an override and an
|
|
166
|
+
* override cannot be switched off. This restores the symmetry: now neither half depends on the
|
|
167
|
+
* caller's options.
|
|
168
|
+
*
|
|
169
|
+
* NOT CURRENTLY REACHABLE BY AN UNTRUSTED CALLER, and this is deliberately belt-and-braces
|
|
170
|
+
* rather than a live hole. Every wire path that accepts `ReplayOnly` was enumerated: the
|
|
171
|
+
* GraphQL `options___`/`DeleteOptionsInput` input exists only on the DELETE mutation (create and
|
|
172
|
+
* update carry no options input, and `ResolverBase.CreateRecord`/`UpdateRecord` call `Save()`
|
|
173
|
+
* with none); REST's `EntityCRUDHandler` does accept it, but calls `entity.Validate()`
|
|
174
|
+
* explicitly before `Save()`, so the guard still runs there; and `graphQLSystemUserClient`
|
|
175
|
+
* requires the system API key, i.e. a caller who is already superuser. The point is that the
|
|
176
|
+
* class docstring's "holds on EVERY write path" is a promise a future wire path forwarding save
|
|
177
|
+
* options would otherwise quietly break — a guard whose protection is one option away from off
|
|
178
|
+
* is not the guard this file claims to be.
|
|
179
|
+
*
|
|
180
|
+
* WHY REFUSE RATHER THAN RE-RUN THE INVARIANTS. Re-running invariants 1-4 here would duplicate
|
|
181
|
+
* `Validate()`'s logic in a second place that must then be kept in step with it — the exact
|
|
182
|
+
* duplicated-decision this repo's design rules call out. Refusing outright is smaller and
|
|
183
|
+
* strictly safer: `ReplayOnly` is a replication/replay facility for trusted sync paths, and a
|
|
184
|
+
* non-Owner has no legitimate reason to replay writes onto the user table. Owners are exempt,
|
|
185
|
+
* so replication and admin paths that run as an Owner are unaffected.
|
|
186
|
+
*/
|
|
187
|
+
async Save(options) {
|
|
188
|
+
if (options?.ReplayOnly && !this.callerIsOwner()) {
|
|
189
|
+
return this.refuseSave();
|
|
190
|
+
}
|
|
191
|
+
return super.Save(options);
|
|
192
|
+
}
|
|
193
|
+
refuseSave() {
|
|
194
|
+
const result = new BaseEntityResult();
|
|
195
|
+
result.Success = false;
|
|
196
|
+
result.Type = this.IsSaved ? 'update' : 'create';
|
|
197
|
+
result.Message =
|
|
198
|
+
'Only an Owner may perform a ReplayOnly save on a user record. ReplayOnly bypasses ' +
|
|
199
|
+
'validation, which is where this entity\'s privilege-elevation invariants are enforced.';
|
|
200
|
+
result.StartedAt = new Date();
|
|
201
|
+
result.EndedAt = new Date();
|
|
202
|
+
this.RegisterResultHistoryEntry(result);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Refuses deletion of any `MJ: Users` row by a non-Owner caller. See invariant 5 above for why:
|
|
207
|
+
* MJ deactivates users via `IsActive` rather than deleting them, and an unguarded delete would
|
|
208
|
+
* let a non-Owner remove any account, including Owner accounts this class's other exemptions
|
|
209
|
+
* depend on.
|
|
210
|
+
*
|
|
211
|
+
* Reports the refusal the way `MJListEntityServer.Delete` does for its own row-level DELETE
|
|
212
|
+
* authorization check — a `BaseEntityResult` on the result history so `LatestResult.CompleteMessage`
|
|
213
|
+
* carries the reason (`Delete()` returns `false` on a logical rejection rather than throwing; see
|
|
214
|
+
* the CLAUDE.md Save/Delete error-handling contract) — rather than `MJUserRoutineEntityServer`'s
|
|
215
|
+
* `Delete()` override, which is FK-cleanup ordering, not an authorization decision, and reports
|
|
216
|
+
* nothing beyond a bare `false`.
|
|
217
|
+
*/
|
|
218
|
+
async Delete(options) {
|
|
219
|
+
if (!this.callerIsOwner()) {
|
|
220
|
+
return this.refuseDelete();
|
|
221
|
+
}
|
|
222
|
+
return super.Delete(options);
|
|
223
|
+
}
|
|
224
|
+
refuseDelete() {
|
|
225
|
+
const result = new BaseEntityResult();
|
|
226
|
+
result.Success = false;
|
|
227
|
+
result.Type = 'delete';
|
|
228
|
+
result.Message =
|
|
229
|
+
'Only an Owner may delete a user record. MJ deactivates users via IsActive rather than deleting them.';
|
|
230
|
+
result.StartedAt = new Date();
|
|
231
|
+
result.EndedAt = new Date();
|
|
232
|
+
this.RegisterResultHistoryEntry(result);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Invariant 1 — a non-Owner may not create a `MJ: Users` row at all. See the class docstring
|
|
237
|
+
* for why this is the correct scope (not merely "may not create an Owner"): `Name` has no
|
|
238
|
+
* unique index, so restricting only `Type='Owner'` on create would leave the INSERT path open
|
|
239
|
+
* to the same principal-redirection invariant 4 blocks on UPDATE.
|
|
240
|
+
*/
|
|
241
|
+
validateCreateRefused(result) {
|
|
242
|
+
// Whole-record refusal, not a field-value problem — no field in this repo's convention
|
|
243
|
+
// exists for that (surveyed every ValidationErrorInfo call site under custom/*.server.ts;
|
|
244
|
+
// all attribute to the specific field the value is wrong for). Attributing to 'Type' would
|
|
245
|
+
// make a form highlight Type for a refusal that has nothing to do with its value; 'ID' is
|
|
246
|
+
// the closer fit — it is the field that identifies WHICH record is being refused.
|
|
247
|
+
result.Errors.push(new ValidationErrorInfo('ID', 'Only an Owner may create a user record.', this.ID, ValidationErrorType.Failure));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Invariant 2 — a non-Owner may not change `Type` on an EXISTING row (self-promotion, or
|
|
251
|
+
* demoting somebody else). Non-Owner creation is refused entirely by `validateCreateRefused`,
|
|
252
|
+
* so this method only needs to consider updates — `Validate()` only calls it on that branch.
|
|
253
|
+
*/
|
|
254
|
+
validateNoTypeChange(result) {
|
|
255
|
+
if (!(this.GetFieldByName('Type')?.Dirty ?? false)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
result.Errors.push(new ValidationErrorInfo('Type', 'Only an Owner may set or change a user\'s Type. Type is the column MJ\'s Owner checks read, ' +
|
|
259
|
+
'so changing it grants platform-superuser access.', this.Type, ValidationErrorType.Failure));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Invariant 3 — a non-Owner may only modify their own user row, compared against the PRE-SAVE
|
|
263
|
+
* `ID` (see the class docstring for why the pre-save value is the meaningful comparison here).
|
|
264
|
+
*
|
|
265
|
+
* Fails CLOSED: if the pre-save identity cannot be established at all, the save is refused
|
|
266
|
+
* rather than silently permitted. This is currently unreachable in production only because
|
|
267
|
+
* `MJ: Users` has `TrackRecordChanges=1`, which forces `ResolverBase.UpdateRecord` to genuinely
|
|
268
|
+
* load the row from the database before applying edits — but that is an unrelated entity flag,
|
|
269
|
+
* not a guarantee this class should assume will always hold.
|
|
270
|
+
*/
|
|
271
|
+
validateOwnRowOnly(result) {
|
|
272
|
+
const preSaveId = this.GetFieldByName('ID')?.OldValue;
|
|
273
|
+
if (!preSaveId) {
|
|
274
|
+
result.Errors.push(new ValidationErrorInfo('ID', 'Could not determine this record\'s pre-save identity, so ownership cannot be verified. ' +
|
|
275
|
+
'Refusing to save rather than risk permitting an edit to another user\'s row.', preSaveId ?? null, ValidationErrorType.Failure));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!UUIDsEqual(preSaveId, this.ActiveUser.ID)) {
|
|
279
|
+
result.Errors.push(new ValidationErrorInfo('ID', 'You may only modify your own user record. Changing another user\'s record requires an Owner.', preSaveId, ValidationErrorType.Failure));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Invariant 4 — a non-Owner may not change `Name` on an existing row.
|
|
284
|
+
*
|
|
285
|
+
* `Name` is the column the context-user ladder resolves against first, so it is effectively a
|
|
286
|
+
* capability name, not a display name. Non-Owner creation (where auto-provisioning legitimately
|
|
287
|
+
* sets `Name = email`) is already refused entirely by `validateCreateRefused`, so this method
|
|
288
|
+
* only runs on updates.
|
|
289
|
+
*
|
|
290
|
+
* `Email` is deliberately NOT frozen alongside `Name` — see the class docstring's invariant 4
|
|
291
|
+
* paragraph for why the ladder's other rung doesn't need the same treatment.
|
|
292
|
+
*/
|
|
293
|
+
validateNameImmutable(result) {
|
|
294
|
+
if (!(this.GetFieldByName('Name')?.Dirty ?? false)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
result.Errors.push(new ValidationErrorInfo('Name', 'Only an Owner may change a user\'s Name. Name is the identifier MJ\'s configured-principal ' +
|
|
298
|
+
'resolution matches against, so changing it can redirect which user the server acts as. ' +
|
|
299
|
+
'Update FirstName, LastName or Title instead.', this.Name, ValidationErrorType.Failure));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* True when the caller is an Owner (or when there is no caller to evaluate — the guard has
|
|
303
|
+
* nothing to compare against).
|
|
304
|
+
*
|
|
305
|
+
* The "no caller ⇒ exempt" default has DIFFERENT reachability for `Save()` vs. `Delete()`:
|
|
306
|
+
* - `Save()`: `BaseEntity.CheckPermissions` throws on a falsy `ActiveUser`
|
|
307
|
+
* (`baseEntity.ts:4003-4005`) and runs at `baseEntity.ts:3702`, BEFORE `Validate()` is
|
|
308
|
+
* called at `baseEntity.ts:3730` — so a caller-less `Save()` never reaches this method at
|
|
309
|
+
* all in production. The default is effectively decorative there.
|
|
310
|
+
* - `Delete()`: the sequencing is the OPPOSITE. THIS class's `Delete()` override calls
|
|
311
|
+
* `callerIsOwner()` as its very first statement, before `super.Delete()` is ever invoked —
|
|
312
|
+
* `CheckPermissions` only runs later, INSIDE `super.Delete()` (`baseEntity.ts:4612`). So a
|
|
313
|
+
* caller-less `Delete()` call DOES reach this method first, and the "no caller ⇒ exempt"
|
|
314
|
+
* default here is load-bearing: it lets the call proceed into `super.Delete()`, where
|
|
315
|
+
* `CheckPermissions` is the thing that actually refuses it. If this default were flipped to
|
|
316
|
+
* "no caller ⇒ refuse", a caller-less delete would be refused by `refuseDelete()` instead —
|
|
317
|
+
* same ultimate outcome (refused), different refusal mechanism and message.
|
|
318
|
+
*
|
|
319
|
+
* `Type` is an `NCHAR` column, so it arrives space-padded; casing is normalized for the same
|
|
320
|
+
* reason `principals.ts` does. Reads `ActiveUser` rather than `ContextCurrentUser` directly so
|
|
321
|
+
* a per-request provider's `CurrentUser` is honored on multi-provider servers.
|
|
322
|
+
*/
|
|
323
|
+
callerIsOwner() {
|
|
324
|
+
const caller = this.ActiveUser;
|
|
325
|
+
if (!caller) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return caller.Type?.trim().toLowerCase() === 'owner';
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
MJUserEntityServer = __decorate([
|
|
332
|
+
RegisterClass(BaseEntity, 'MJ: Users')
|
|
333
|
+
], MJUserEntityServer);
|
|
334
|
+
export { MJUserEntityServer };
|
|
335
|
+
//# sourceMappingURL=MJUserEntityServer.server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MJUserEntityServer.server.js","sourceRoot":"","sources":["../../src/custom/MJUserEntityServer.server.ts"],"names":[],"mappings":"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"}
|