@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
|
@@ -10,6 +10,7 @@ jest.mock('next/headers', () => ({
|
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
12
|
jest.mock('@open-mercato/shared/lib/auth/jwt', () => ({
|
|
13
|
+
...jest.requireActual('@open-mercato/shared/lib/auth/jwt'),
|
|
13
14
|
verifyJwt: (...args: unknown[]) => verifyJwt(...args),
|
|
14
15
|
}))
|
|
15
16
|
|
|
@@ -162,6 +163,109 @@ describe('auth server integrity checks', () => {
|
|
|
162
163
|
await expect(getAuthFromRequest(request)).resolves.toBeNull()
|
|
163
164
|
})
|
|
164
165
|
|
|
166
|
+
describe('mfa-pending staff tokens', () => {
|
|
167
|
+
const pendingAuth = {
|
|
168
|
+
sub: '11111111-1111-4111-8111-111111111111',
|
|
169
|
+
sid: '44444444-4444-4444-8444-444444444444',
|
|
170
|
+
tenantId: '22222222-2222-4222-8222-222222222222',
|
|
171
|
+
orgId: '33333333-3333-4333-8333-333333333333',
|
|
172
|
+
roles: ['admin'],
|
|
173
|
+
mfa_pending: true,
|
|
174
|
+
mfa_verified: false,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mfaPendingRequest(path: string, method: string): Request {
|
|
178
|
+
return new Request(`https://example.test${path}`, {
|
|
179
|
+
method,
|
|
180
|
+
headers: { authorization: `Bearer pending-token` },
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
beforeEach(async () => {
|
|
185
|
+
// The registry ships empty; completion routes are registered by their owning module
|
|
186
|
+
// (enterprise security). Register equivalent fixtures to exercise resolver mechanics.
|
|
187
|
+
const { registerMfaPendingAccessRoutes } = await import('../mfaPendingAccess')
|
|
188
|
+
registerMfaPendingAccessRoutes([
|
|
189
|
+
{ path: '/api/security/mfa/prepare', methods: ['POST'] },
|
|
190
|
+
{ path: '/api/security/mfa/verify', methods: ['POST'] },
|
|
191
|
+
{ path: '/api/security/mfa/recovery', methods: ['POST'] },
|
|
192
|
+
])
|
|
193
|
+
verifyJwt.mockReturnValue(pendingAuth)
|
|
194
|
+
resolveCanonicalStaffAuthContext.mockResolvedValue({ ...pendingAuth })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('rejects a pending token on a general staff API before roles are restored', async () => {
|
|
198
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
199
|
+
|
|
200
|
+
await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/customers/people', 'GET')))
|
|
201
|
+
.resolves.toEqual({ auth: null, status: 'invalid' })
|
|
202
|
+
expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('still resolves a pending token on the registered MFA completion routes (POST)', async () => {
|
|
206
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
207
|
+
|
|
208
|
+
for (const path of ['/api/security/mfa/prepare', '/api/security/mfa/verify', '/api/security/mfa/recovery']) {
|
|
209
|
+
resolveCanonicalStaffAuthContext.mockClear()
|
|
210
|
+
const resolution = await resolveAuthFromRequestDetailed(mfaPendingRequest(path, 'POST'))
|
|
211
|
+
expect(resolution.status).toBe('authenticated')
|
|
212
|
+
expect(resolution.auth).toEqual({ ...pendingAuth })
|
|
213
|
+
expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, pendingAuth)
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('rejects a pending token with a non-completion method on a completion route', async () => {
|
|
218
|
+
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
219
|
+
|
|
220
|
+
await expect(resolveAuthFromRequestDetailed(mfaPendingRequest('/api/security/mfa/verify', 'GET')))
|
|
221
|
+
.resolves.toEqual({ auth: null, status: 'invalid' })
|
|
222
|
+
expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('resolves the verified replacement token like a normal login', async () => {
|
|
226
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
227
|
+
const verifiedAuth = { ...pendingAuth, mfa_pending: false, mfa_verified: true }
|
|
228
|
+
verifyJwt.mockReturnValue(verifiedAuth)
|
|
229
|
+
resolveCanonicalStaffAuthContext.mockResolvedValue({ ...verifiedAuth, roles: ['employee'] })
|
|
230
|
+
|
|
231
|
+
const request = new Request('https://example.test/api/customers/people', {
|
|
232
|
+
headers: { authorization: 'Bearer verified-token' },
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
await expect(getAuthFromRequest(request)).resolves.toEqual({ ...verifiedAuth, roles: ['employee'] })
|
|
236
|
+
expect(resolveCanonicalStaffAuthContext).toHaveBeenCalledWith(em, verifiedAuth)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('leaves tokens without MFA claims untouched', async () => {
|
|
240
|
+
const { getAuthFromRequest } = await import('@open-mercato/shared/lib/auth/server')
|
|
241
|
+
const plainAuth = {
|
|
242
|
+
sub: '11111111-1111-4111-8111-111111111111',
|
|
243
|
+
tenantId: '22222222-2222-4222-8222-222222222222',
|
|
244
|
+
orgId: '33333333-3333-4333-8333-333333333333',
|
|
245
|
+
roles: [],
|
|
246
|
+
}
|
|
247
|
+
verifyJwt.mockReturnValue(plainAuth)
|
|
248
|
+
resolveCanonicalStaffAuthContext.mockResolvedValue(plainAuth)
|
|
249
|
+
|
|
250
|
+
const request = new Request('https://example.test/api/test', {
|
|
251
|
+
headers: { cookie: 'auth_token=jwt-token' },
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await expect(getAuthFromRequest(request)).resolves.toEqual(plainAuth)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('rejects a pending token from cookie-based page resolution unconditionally', async () => {
|
|
258
|
+
const { getAuthFromCookies, resolveAuthFromCookiesDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
259
|
+
cookieStore.get.mockImplementation((name: string) => {
|
|
260
|
+
if (name === 'auth_token') return { value: 'pending-jwt-token' }
|
|
261
|
+
return undefined
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await expect(getAuthFromCookies()).resolves.toBeNull()
|
|
265
|
+
await expect(resolveAuthFromCookiesDetailed()).resolves.toEqual({ auth: null, status: 'invalid' })
|
|
266
|
+
expect(resolveCanonicalStaffAuthContext).not.toHaveBeenCalled()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
165
269
|
it('reports a transient DB failure on the api-key path as "error" (retryable 503)', async () => {
|
|
166
270
|
const { resolveAuthFromRequestDetailed } = await import('@open-mercato/shared/lib/auth/server')
|
|
167
271
|
// Distinct secret so the shared api-key auth cache does not serve a prior miss.
|
package/src/lib/auth/jwt.ts
CHANGED
|
@@ -377,3 +377,20 @@ export function signAudienceJwt(
|
|
|
377
377
|
export function verifyAudienceJwt(audience: string, token: string): JwtPayload | null {
|
|
378
378
|
return verifyJwt(token, { audience })
|
|
379
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* True when a verified JWT payload represents an MFA challenge in progress: the second factor
|
|
383
|
+
* has not been completed yet (`mfa_pending: true`, `mfa_verified` not `true`). Such tokens are
|
|
384
|
+
* provisional — general staff APIs must reject them and only the MFA completion routes may
|
|
385
|
+
* accept them (see `@open-mercato/shared/lib/auth/mfaPendingAccess`).
|
|
386
|
+
*
|
|
387
|
+
* The predicate keys on `mfa_pending` alone by design. `mfa_verified` is NOT a durable session
|
|
388
|
+
* property: routes that legitimately re-sign a staff token (`PUT /api/auth/profile`,
|
|
389
|
+
* `GET|POST /api/auth/session/refresh`) rebuild the claims without it. Tightening this to require
|
|
390
|
+
* `mfa_verified === true` would therefore silently downgrade those sessions.
|
|
391
|
+
*/
|
|
392
|
+
export function isMfaPendingJwtPayload(payload: unknown): boolean {
|
|
393
|
+
if (!payload || typeof payload !== 'object') return false
|
|
394
|
+
const record = payload as Record<string, unknown>
|
|
395
|
+
return record.mfa_pending === true && record.mfa_verified !== true
|
|
396
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type MfaPendingAccessRoute = {
|
|
2
|
+
path: string
|
|
3
|
+
methods: string[]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const registeredMethodsByPath = new Map<string, Set<string>>()
|
|
7
|
+
|
|
8
|
+
function normalizePath(path: string): string | null {
|
|
9
|
+
if (typeof path !== 'string') return null
|
|
10
|
+
const trimmed = path.trim()
|
|
11
|
+
if (!trimmed || !trimmed.startsWith('/')) return null
|
|
12
|
+
return (trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed).toLowerCase() || '/'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeMethods(methods: unknown): string[] {
|
|
16
|
+
if (!Array.isArray(methods)) return []
|
|
17
|
+
const normalized = methods
|
|
18
|
+
.filter((method): method is string => typeof method === 'string' && method.trim().length > 0)
|
|
19
|
+
.map((method) => method.trim().toUpperCase())
|
|
20
|
+
return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register routes that may accept an MFA-pending staff token. The registry starts empty and
|
|
25
|
+
* stays generic: every module that completes an MFA challenge on its own endpoints registers
|
|
26
|
+
* them here during its bootstrap — the canonical `/api/security/mfa/*` completion routes are
|
|
27
|
+
* registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party
|
|
28
|
+
* MFA implementations register theirs the same way. Registration is additive: methods merge
|
|
29
|
+
* into any existing entry for the same path and are never removed.
|
|
30
|
+
*
|
|
31
|
+
* Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`
|
|
32
|
+
* (§ Amendment 2026-08-21 — central MFA-pending token gate).
|
|
33
|
+
*/
|
|
34
|
+
export function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {
|
|
35
|
+
for (const route of Array.isArray(routes) ? routes : []) {
|
|
36
|
+
const path = normalizePath(route?.path ?? '')
|
|
37
|
+
const methods = normalizeMethods(route?.methods)
|
|
38
|
+
if (!path || !methods.length) continue
|
|
39
|
+
const existing = registeredMethodsByPath.get(path)
|
|
40
|
+
if (!existing) {
|
|
41
|
+
registeredMethodsByPath.set(path, new Set(methods))
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
for (const method of methods) existing.add(method)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact
|
|
50
|
+
* method + path pair. Only explicitly registered completion routes pass; every unregistered
|
|
51
|
+
* request is denied.
|
|
52
|
+
*/
|
|
53
|
+
export function isMfaPendingAccessAllowed(
|
|
54
|
+
method: string | null | undefined,
|
|
55
|
+
pathname: string | null | undefined,
|
|
56
|
+
): boolean {
|
|
57
|
+
const path = normalizePath(typeof pathname === 'string' ? pathname : '')
|
|
58
|
+
if (!path) return false
|
|
59
|
+
if (typeof method !== 'string' || method.trim().length === 0) return false
|
|
60
|
+
const methods = registeredMethodsByPath.get(path)
|
|
61
|
+
if (!methods) return false
|
|
62
|
+
return methods.has(method.trim().toUpperCase())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test/ops helper: current snapshot of the pending-access registry. */
|
|
66
|
+
export function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {
|
|
67
|
+
return Array.from(registeredMethodsByPath.entries())
|
|
68
|
+
.map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))
|
|
69
|
+
.sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))
|
|
70
|
+
}
|
|
@@ -4,6 +4,15 @@ export type OrganizationAccessDecisionInput = {
|
|
|
4
4
|
targetOrganizationId: string | null
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export type UnrestrictedOrganizationScopeInput = {
|
|
8
|
+
isSuperAdmin: boolean
|
|
9
|
+
allowedOrganizationIds: readonly string[] | null | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isUnrestrictedOrganizationScope(input: UnrestrictedOrganizationScopeInput): boolean {
|
|
13
|
+
return input.isSuperAdmin || input.allowedOrganizationIds === null
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
/**
|
|
8
17
|
* Fail-closed organization-access predicate. The single source of truth for
|
|
9
18
|
* "may this principal act on `targetOrganizationId`".
|
|
@@ -15,8 +24,7 @@ export type OrganizationAccessDecisionInput = {
|
|
|
15
24
|
* - restricted + target org -> allow iff the target is a member of the allowed set
|
|
16
25
|
*/
|
|
17
26
|
export function isOrganizationAccessAllowed(input: OrganizationAccessDecisionInput): boolean {
|
|
18
|
-
if (input
|
|
19
|
-
if (input.allowedOrganizationIds === null) return true
|
|
27
|
+
if (isUnrestrictedOrganizationScope(input)) return true
|
|
20
28
|
if (!input.targetOrganizationId) return false
|
|
21
|
-
return input.allowedOrganizationIds.includes(input.targetOrganizationId)
|
|
29
|
+
return (input.allowedOrganizationIds ?? []).includes(input.targetOrganizationId)
|
|
22
30
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AuthContext } from './server'
|
|
2
|
+
|
|
3
|
+
export type PrincipalScope = {
|
|
4
|
+
tenantId: string
|
|
5
|
+
organizationId: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type AuthPrincipalType = 'user' | 'role'
|
|
9
|
+
|
|
10
|
+
export type AuthPrincipalLabel = {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
secondary: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type AuthPrincipalRolePage = {
|
|
17
|
+
items: AuthPrincipalLabel[]
|
|
18
|
+
page: number
|
|
19
|
+
pageSize: number
|
|
20
|
+
total: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Public, request-scoped read boundary owned by the Auth module. */
|
|
24
|
+
export interface AuthPrincipalService {
|
|
25
|
+
principalExists(input: {
|
|
26
|
+
type: AuthPrincipalType
|
|
27
|
+
id: string
|
|
28
|
+
scope: PrincipalScope
|
|
29
|
+
}): Promise<boolean>
|
|
30
|
+
resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
|
|
31
|
+
filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
|
|
32
|
+
resolveLabels(input: {
|
|
33
|
+
type: AuthPrincipalType
|
|
34
|
+
ids: string[]
|
|
35
|
+
scope: PrincipalScope
|
|
36
|
+
}): Promise<AuthPrincipalLabel[]>
|
|
37
|
+
/**
|
|
38
|
+
* Optional additive capability for organization-eligible role pickers.
|
|
39
|
+
* Implementations must apply eligibility before pagination and bound the
|
|
40
|
+
* advertised result window so sparse ACL matches cannot amplify requests.
|
|
41
|
+
*/
|
|
42
|
+
queryActiveRolePage?(input: {
|
|
43
|
+
scope: PrincipalScope
|
|
44
|
+
search?: string
|
|
45
|
+
excludedIds?: string[]
|
|
46
|
+
page: number
|
|
47
|
+
pageSize: number
|
|
48
|
+
}): Promise<AuthPrincipalRolePage>
|
|
49
|
+
listSuperAdminUserIds(tenantId: string): Promise<string[]>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Public, request-scoped read boundary owned by the API Keys module. */
|
|
53
|
+
export interface ApiKeyPrincipalService {
|
|
54
|
+
resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type OrganizationScope = {
|
|
58
|
+
selectedId: string | null
|
|
59
|
+
filterIds: string[] | null
|
|
60
|
+
allowedIds: string[] | null
|
|
61
|
+
tenantId: string | null
|
|
62
|
+
// True when an explicit organization selection could not be honored. Reads
|
|
63
|
+
// fall back to the caller's accessible organizations; writes must fail.
|
|
64
|
+
selectionRejected?: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type OrganizationScopeAcl = {
|
|
68
|
+
isSuperAdmin: boolean
|
|
69
|
+
features: string[]
|
|
70
|
+
organizations: string[] | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type OrganizationScopeRequest = Request | {
|
|
74
|
+
cookies?: { get: (name: string) => { value: string } | undefined }
|
|
75
|
+
headers?: { get(name: string): string | null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Narrow, request-scoped hierarchy boundary owned by Directory.
|
|
80
|
+
*
|
|
81
|
+
* `null` means the selected organization does not exist in the requested
|
|
82
|
+
* tenant. An empty array means it exists but has no ancestors.
|
|
83
|
+
*/
|
|
84
|
+
export interface OrganizationHierarchyService {
|
|
85
|
+
resolveAncestorIds(input: {
|
|
86
|
+
tenantId: string
|
|
87
|
+
organizationId: string
|
|
88
|
+
}): Promise<string[] | null>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Public, request-scoped organization expansion boundary owned by Directory. */
|
|
92
|
+
export interface OrganizationScopeService {
|
|
93
|
+
resolve(input: {
|
|
94
|
+
auth: AuthContext | null | undefined
|
|
95
|
+
selectedId?: string | null
|
|
96
|
+
tenantId?: string | null
|
|
97
|
+
freshAcl?: boolean
|
|
98
|
+
}): Promise<OrganizationScope>
|
|
99
|
+
resolveFresh(input: {
|
|
100
|
+
auth: NonNullable<AuthContext>
|
|
101
|
+
selectedId?: string | null
|
|
102
|
+
tenantId?: string | null
|
|
103
|
+
}): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
|
|
104
|
+
resolveForRequest(input: {
|
|
105
|
+
auth: AuthContext | null | undefined
|
|
106
|
+
request?: OrganizationScopeRequest
|
|
107
|
+
selectedId?: string | null
|
|
108
|
+
tenantId?: string | null
|
|
109
|
+
}): Promise<OrganizationScope>
|
|
110
|
+
}
|
package/src/lib/auth/server.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { cookies } from 'next/headers.js'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
-
import { verifyJwt } from './jwt'
|
|
3
|
+
import { isMfaPendingJwtPayload, verifyJwt } from './jwt'
|
|
4
|
+
import { isMfaPendingAccessAllowed } from './mfaPendingAccess'
|
|
4
5
|
import { getSharedApiKeyAuthCache } from './apiKeyAuthCache'
|
|
5
6
|
import { isTransientDbError } from '@open-mercato/shared/lib/db/pg-errors'
|
|
6
7
|
|
|
8
|
+
function readRequestPathname(req: Request): string | null {
|
|
9
|
+
try {
|
|
10
|
+
return new URL(req.url).pathname
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
const TENANT_COOKIE_NAME = 'om_selected_tenant'
|
|
8
17
|
const ORGANIZATION_COOKIE_NAME = 'om_selected_org'
|
|
9
18
|
const ALL_ORGANIZATIONS_COOKIE_VALUE = '__all__'
|
|
@@ -59,6 +68,16 @@ export type TrustedAuthContextEnvelope = {
|
|
|
59
68
|
status?: AuthResolutionStatus
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Attach an already-resolved auth context to a synthetic request.
|
|
73
|
+
*
|
|
74
|
+
* INVARIANT callers MUST uphold: `envelope.auth` has to be the product of a gated resolution —
|
|
75
|
+
* `getAuthFromRequest` / `getAuthFromCookies` (or an equivalently validated principal such as an
|
|
76
|
+
* API-key record). The envelope short-circuits `resolveAuthFromRequestDetailed` ahead of both the
|
|
77
|
+
* MFA-pending gate and `resolveCanonicalStaffAuthContext`, so an envelope built straight from an
|
|
78
|
+
* unvalidated JWT would reinstate the MFA bypass this module closes and skip session-revocation
|
|
79
|
+
* checks as well. Never populate it from raw request credentials.
|
|
80
|
+
*/
|
|
62
81
|
export function attachTrustedAuthContext(
|
|
63
82
|
request: Request,
|
|
64
83
|
envelope: TrustedAuthContextEnvelope
|
|
@@ -195,13 +214,33 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
195
214
|
: []
|
|
196
215
|
const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
|
|
197
216
|
|
|
217
|
+
// A role-level super-admin grant is authorization-grade only when it is genuinely
|
|
218
|
+
// unrestricted: the grant itself must not be organization-scoped, it must belong to the
|
|
219
|
+
// key's tenant, and the key must not be bound to a single organization. RbacService applies
|
|
220
|
+
// the same intersection when projecting the scoped ACL, but generic guards
|
|
221
|
+
// (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
|
|
222
|
+
// live RBAC runs — so an organization-restricted key must never carry it.
|
|
198
223
|
let keyIsSuperAdmin = false
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
|
|
225
|
+
? record.organizationId.trim()
|
|
226
|
+
: null
|
|
227
|
+
if (roleIds.length && !keyOrganizationId) {
|
|
228
|
+
const superAcls = await em.find(
|
|
201
229
|
RoleAcl,
|
|
202
|
-
{
|
|
230
|
+
{
|
|
231
|
+
role: { $in: roleIds } as any,
|
|
232
|
+
tenantId: record.tenantId ?? null,
|
|
233
|
+
isSuperAdmin: true,
|
|
234
|
+
deletedAt: null,
|
|
235
|
+
} as any,
|
|
203
236
|
)
|
|
204
|
-
keyIsSuperAdmin =
|
|
237
|
+
keyIsSuperAdmin = superAcls.some((acl) => {
|
|
238
|
+
const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
|
|
239
|
+
? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
|
|
240
|
+
: null
|
|
241
|
+
// An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
|
|
242
|
+
return !organizations || organizations.length === 0 || organizations.includes('__all__')
|
|
243
|
+
})
|
|
205
244
|
}
|
|
206
245
|
|
|
207
246
|
if (cache.shouldWriteLastUsed(record.id)) {
|
|
@@ -213,8 +252,25 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
213
252
|
}
|
|
214
253
|
}
|
|
215
254
|
|
|
216
|
-
//
|
|
217
|
-
|
|
255
|
+
// Ephemeral session keys are always user-bound. Regular keys retain their
|
|
256
|
+
// legacy creator identity, while tenant-scoped regular keys ignore only the
|
|
257
|
+
// creator's concrete organization when validating the wider key scope.
|
|
258
|
+
// Keep every session marker fail-closed so a malformed session key cannot
|
|
259
|
+
// fall back to the regular key path and escape its user/scope binding.
|
|
260
|
+
const isSessionBoundKey = Boolean(
|
|
261
|
+
record.sessionToken
|
|
262
|
+
|| record.sessionUserId
|
|
263
|
+
|| record.sessionSecretEncrypted
|
|
264
|
+
|| record.opencodeSessionId
|
|
265
|
+
)
|
|
266
|
+
const actualUserId = isSessionBoundKey
|
|
267
|
+
? record.sessionUserId ?? null
|
|
268
|
+
: record.createdBy ?? null
|
|
269
|
+
|
|
270
|
+
if (isSessionBoundKey && !actualUserId) {
|
|
271
|
+
cache.setMiss(secret)
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
218
274
|
|
|
219
275
|
if (actualUserId) {
|
|
220
276
|
const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
|
|
@@ -226,7 +282,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
|
|
|
226
282
|
cache.setMiss(secret)
|
|
227
283
|
return null
|
|
228
284
|
}
|
|
229
|
-
|
|
285
|
+
const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
|
|
286
|
+
if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
|
|
230
287
|
cache.setMiss(secret)
|
|
231
288
|
return null
|
|
232
289
|
}
|
|
@@ -315,6 +372,9 @@ export async function resolveAuthFromCookiesDetailed(): Promise<AuthResolution>
|
|
|
315
372
|
const payload = verifyJwt(token) as AuthContext
|
|
316
373
|
if (!payload) return { auth: null, status: 'invalid' }
|
|
317
374
|
if (payload.type === 'customer') return { auth: null, status: 'invalid' }
|
|
375
|
+
// MFA-pending tokens are provisional: pages never complete the second factor, so they
|
|
376
|
+
// must not resolve to an authenticated context anywhere in the RSC/page flow.
|
|
377
|
+
if (isMfaPendingJwtPayload(payload)) return { auth: null, status: 'invalid' }
|
|
318
378
|
const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
|
|
319
379
|
if (!canonicalAuth) return { auth: null, status: 'invalid' }
|
|
320
380
|
const tenantCookie = cookieStore.get(TENANT_COOKIE_NAME)?.value
|
|
@@ -334,6 +394,8 @@ export async function getAuthFromCookies(): Promise<AuthContext> {
|
|
|
334
394
|
}
|
|
335
395
|
|
|
336
396
|
export async function resolveAuthFromRequestDetailed(req: Request): Promise<AuthResolution> {
|
|
397
|
+
// Deliberately ahead of the MFA-pending gate below: the envelope carries a context that was
|
|
398
|
+
// already gated by its producer (see `attachTrustedAuthContext`), not raw request credentials.
|
|
337
399
|
const trusted = readTrustedAuthContext(req)
|
|
338
400
|
if (trusted) {
|
|
339
401
|
return {
|
|
@@ -357,6 +419,14 @@ export async function resolveAuthFromRequestDetailed(req: Request): Promise<Auth
|
|
|
357
419
|
try {
|
|
358
420
|
const payload = verifyJwt(token) as AuthContext
|
|
359
421
|
if (payload && payload.type === 'customer') return { auth: null, status: 'invalid' }
|
|
422
|
+
if (payload && isMfaPendingJwtPayload(payload)) {
|
|
423
|
+
// MFA-pending tokens authenticate only the registered challenge-completion routes;
|
|
424
|
+
// everywhere else they resolve to `invalid` so the dispatcher answers 401 (clearing
|
|
425
|
+
// staff auth cookies) instead of restoring the account's roles/features.
|
|
426
|
+
if (!isMfaPendingAccessAllowed(req.method, readRequestPathname(req))) {
|
|
427
|
+
return { auth: null, status: 'invalid' }
|
|
428
|
+
}
|
|
429
|
+
}
|
|
360
430
|
if (payload) {
|
|
361
431
|
const canonicalAuth = await resolveCanonicalInteractiveAuthContext(payload)
|
|
362
432
|
if (canonicalAuth) {
|
|
@@ -49,6 +49,37 @@ describe('CommandBus', () => {
|
|
|
49
49
|
expect(logEntry).toEqual({ id: 'log-entry' })
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
it('records the system actor marker when a trusted command has no auth actor', async () => {
|
|
53
|
+
const logMock = jest.fn(async () => ({ id: 'system-log-entry' }))
|
|
54
|
+
registerCommand({
|
|
55
|
+
id: 'test.system-command',
|
|
56
|
+
execute: jest.fn(async () => ({ ok: true })),
|
|
57
|
+
buildLog: jest.fn(() => ({ actionLabel: 'System test', resourceKind: 'test', resourceId: 'system-123' })),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
61
|
+
container.register({ actionLogService: asValue({ log: logMock }) })
|
|
62
|
+
|
|
63
|
+
const bus = new CommandBus()
|
|
64
|
+
const ctx = {
|
|
65
|
+
container,
|
|
66
|
+
auth: null,
|
|
67
|
+
organizationScope: null,
|
|
68
|
+
selectedOrganizationId: null,
|
|
69
|
+
organizationIds: null,
|
|
70
|
+
systemActor: true,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await bus.execute('test.system-command', { input: {}, ctx })
|
|
74
|
+
|
|
75
|
+
expect(logMock).toHaveBeenCalledWith(
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
actorUserId: undefined,
|
|
78
|
+
context: { systemActor: 'system:command' },
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
52
83
|
it('passes captureAfter snapshot to buildLog as snapshots.after', async () => {
|
|
53
84
|
const logMock = jest.fn(async () => ({ id: 'log-entry-2' }))
|
|
54
85
|
const buildLogMock = jest.fn(() => ({
|
|
@@ -583,6 +583,9 @@ export class CommandBus {
|
|
|
583
583
|
const organizationId =
|
|
584
584
|
metadata.organizationId ?? options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null
|
|
585
585
|
const actorUserId = metadata.actorUserId ?? options.ctx.auth?.sub ?? null
|
|
586
|
+
const systemActorContext = !actorUserId && options.ctx.systemActor === true
|
|
587
|
+
? { systemActor: 'system:command' }
|
|
588
|
+
: null
|
|
586
589
|
const payload: Record<string, unknown> = {
|
|
587
590
|
tenantId: tenantId ?? undefined,
|
|
588
591
|
organizationId: organizationId ?? undefined,
|
|
@@ -603,7 +606,11 @@ export class CommandBus {
|
|
|
603
606
|
if ('snapshotBefore' in metadata && metadata.snapshotBefore !== undefined) payload.snapshotBefore = metadata.snapshotBefore
|
|
604
607
|
if ('snapshotAfter' in metadata && metadata.snapshotAfter !== undefined) payload.snapshotAfter = metadata.snapshotAfter
|
|
605
608
|
if ('changes' in metadata && metadata.changes !== undefined && metadata.changes !== null) payload.changes = metadata.changes
|
|
606
|
-
if ('context' in metadata && metadata.context !== undefined && metadata.context !== null)
|
|
609
|
+
if ('context' in metadata && metadata.context !== undefined && metadata.context !== null) {
|
|
610
|
+
payload.context = { ...(systemActorContext ?? {}), ...metadata.context }
|
|
611
|
+
} else if (systemActorContext) {
|
|
612
|
+
payload.context = systemActorContext
|
|
613
|
+
}
|
|
607
614
|
}
|
|
608
615
|
|
|
609
616
|
const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, options.input)
|