@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.5
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/dist/bootstrap.js +46 -6
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/organization/index.js +2 -0
- package/dist/generated/entities/organization/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +4 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/attachments/api/route.js +2 -0
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/access.js +18 -0
- package/dist/modules/attachments/lib/access.js.map +2 -2
- package/dist/modules/audit_logs/data/entities.js +2 -1
- package/dist/modules/audit_logs/data/entities.js.map +2 -2
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js +13 -0
- package/dist/modules/audit_logs/migrations/Migration20260611104500.js.map +7 -0
- package/dist/modules/audit_logs/services/accessLogService.js +10 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
- package/dist/modules/auth/api/admin/nav.js +9 -0
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/login.js +4 -13
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/data/entities.js +3 -1
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +35 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/consentIntegrity.js +3 -3
- package/dist/modules/auth/lib/consentIntegrity.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260611103000.js +15 -0
- package/dist/modules/auth/migrations/Migration20260611103000.js.map +7 -0
- package/dist/modules/auth/services/authService.js +5 -3
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +3 -2
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
- package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +43 -2
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +402 -0
- package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +221 -56
- package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +18 -0
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/cli.js +15 -9
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
- package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +100 -17
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +11 -3
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
- package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
- package/dist/modules/customers/lib/dealsMetrics.js +82 -0
- package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
- package/dist/modules/directory/api/organization-branding/route.js +214 -0
- package/dist/modules/directory/api/organization-branding/route.js.map +7 -0
- package/dist/modules/directory/api/organizations/route.js +7 -0
- package/dist/modules/directory/api/organizations/route.js.map +3 -3
- package/dist/modules/directory/backend/directory/branding/page.js +214 -0
- package/dist/modules/directory/backend/directory/branding/page.js.map +7 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js +26 -0
- package/dist/modules/directory/backend/directory/branding/page.meta.js.map +7 -0
- package/dist/modules/directory/commands/organizations.js +8 -1
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +3 -0
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/directory/data/validators.js +9 -0
- package/dist/modules/directory/data/validators.js.map +2 -2
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js +13 -0
- package/dist/modules/directory/migrations/Migration20260607222259_directory.js.map +7 -0
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +59 -27
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +2 -1
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/entities.js +7 -0
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +26 -15
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
- package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
- package/dist/modules/query_index/data/entities.js +2 -1
- package/dist/modules/query_index/data/entities.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +4 -2
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js +16 -0
- package/dist/modules/query_index/migrations/Migration20260611103000_query_index.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +7 -5
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js +2 -1
- package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js +10 -0
- package/dist/modules/sales/components/documents/salesDocumentsColumns.js.map +7 -0
- package/dist/modules/staff/api/team-members.js +9 -2
- package/dist/modules/staff/api/team-members.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
- package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
- package/dist/modules/staff/commands/team-members.js +1 -1
- package/dist/modules/staff/commands/team-members.js.map +2 -2
- package/dist/modules/staff/components/TeamMemberForm.js +1 -1
- package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
- package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
- package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
- package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
- package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
- package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
- package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
- package/generated/entities/organization/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +11 -12
- package/src/bootstrap.ts +65 -7
- package/src/helpers/integration/crmFixtures.ts +21 -1
- package/src/modules/attachments/AGENTS.md +79 -0
- package/src/modules/attachments/api/route.ts +2 -0
- package/src/modules/attachments/lib/access.ts +36 -0
- package/src/modules/audit_logs/data/entities.ts +1 -0
- package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/audit_logs/migrations/Migration20260611104500.ts +13 -0
- package/src/modules/audit_logs/services/accessLogService.ts +15 -0
- package/src/modules/auth/api/admin/nav.ts +9 -0
- package/src/modules/auth/api/login.ts +13 -13
- package/src/modules/auth/data/entities.ts +2 -0
- package/src/modules/auth/i18n/de.json +0 -1
- package/src/modules/auth/i18n/en.json +0 -1
- package/src/modules/auth/i18n/es.json +0 -1
- package/src/modules/auth/i18n/pl.json +0 -1
- package/src/modules/auth/lib/backendChrome.tsx +37 -1
- package/src/modules/auth/lib/consentIntegrity.ts +6 -3
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +20 -0
- package/src/modules/auth/migrations/Migration20260611103000.ts +21 -0
- package/src/modules/auth/services/authService.ts +24 -4
- package/src/modules/auth/services/rbacService.ts +11 -2
- package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
- package/src/modules/customers/api/deals/route.ts +51 -2
- package/src/modules/customers/api/deals/summary/route.ts +496 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
- package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +18 -0
- package/src/modules/customers/cli.ts +15 -15
- package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
- package/src/modules/customers/components/detail/DealForm.tsx +121 -19
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +12 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
- package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
- package/src/modules/customers/i18n/de.json +43 -0
- package/src/modules/customers/i18n/en.json +43 -0
- package/src/modules/customers/i18n/es.json +43 -0
- package/src/modules/customers/i18n/pl.json +43 -0
- package/src/modules/customers/lib/dealsMetrics.ts +159 -0
- package/src/modules/directory/api/organization-branding/route.ts +238 -0
- package/src/modules/directory/api/organizations/route.ts +7 -0
- package/src/modules/directory/backend/directory/branding/page.meta.ts +24 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +248 -0
- package/src/modules/directory/commands/organizations.ts +9 -1
- package/src/modules/directory/data/entities.ts +3 -0
- package/src/modules/directory/data/validators.ts +12 -0
- package/src/modules/directory/i18n/de.json +21 -0
- package/src/modules/directory/i18n/en.json +21 -0
- package/src/modules/directory/i18n/es.json +21 -0
- package/src/modules/directory/i18n/pl.json +21 -0
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +40 -0
- package/src/modules/directory/migrations/Migration20260607222259_directory.ts +13 -0
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
- package/src/modules/directory/utils/organizationScope.ts +85 -30
- package/src/modules/entities/api/definitions.batch.ts +11 -7
- package/src/modules/entities/api/entities.ts +11 -0
- package/src/modules/entities/api/records.ts +46 -25
- package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
- package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
- package/src/modules/entities/i18n/de.json +1 -0
- package/src/modules/entities/i18n/en.json +1 -0
- package/src/modules/entities/i18n/es.json +1 -0
- package/src/modules/entities/i18n/pl.json +1 -0
- package/src/modules/query_index/data/entities.ts +1 -0
- package/src/modules/query_index/lib/engine.ts +11 -5
- package/src/modules/query_index/migrations/.snapshot-open-mercato.json +11 -0
- package/src/modules/query_index/migrations/Migration20260611103000_query_index.ts +29 -0
- package/src/modules/sales/commands/documents.ts +7 -5
- package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +2 -1
- package/src/modules/sales/components/documents/salesDocumentsColumns.ts +6 -0
- package/src/modules/staff/api/team-members.ts +9 -2
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
- package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
- package/src/modules/staff/commands/team-members.ts +5 -2
- package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
- package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
- package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
- package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
- package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
- package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Attachments Module — Agent Guidelines
|
|
2
|
+
|
|
3
|
+
The `attachments` module owns file uploads, storage drivers, partitions, OCR, and
|
|
4
|
+
the `attachments` table. Every attachment row carries a `tenant_id` /
|
|
5
|
+
`organization_id` scope pair that governs cross-tenant access.
|
|
6
|
+
|
|
7
|
+
## Scope & Access Policy
|
|
8
|
+
|
|
9
|
+
`attachments.tenant_id` and `attachments.organization_id` are **nullable** at the
|
|
10
|
+
DB level, but only two scope shapes are valid:
|
|
11
|
+
|
|
12
|
+
| Shape | `tenant_id` | `organization_id` | Meaning | Who can read it |
|
|
13
|
+
|-------|-------------|-------------------|---------|-----------------|
|
|
14
|
+
| **Scoped** | set | set | Belongs to one tenant + org | Same-scope principals + superadmin |
|
|
15
|
+
| **Global** | null | null | Legacy "global attachment" | Any authenticated principal (and unauthenticated only on a `is_public` partition) |
|
|
16
|
+
| **Partial-null** ❌ | set / null | null / set | **Invalid — never create** | Nobody (fail-closed) except superadmin |
|
|
17
|
+
|
|
18
|
+
The "both-or-neither" rule is the legitimate-unscoped use case referenced by
|
|
19
|
+
[#2109](https://github.com/open-mercato/open-mercato/issues/2109): a fully-global
|
|
20
|
+
(both-null) attachment is intentionally supported by `isSameScope`, so the columns
|
|
21
|
+
cannot simply be made `NOT NULL` without breaking that semantic or backfilling a
|
|
22
|
+
sentinel tenant onto legacy rows.
|
|
23
|
+
|
|
24
|
+
### Why partial-null is dangerous
|
|
25
|
+
|
|
26
|
+
`isSameScope` (`lib/access.ts`) deliberately **fails closed** on partial-null rows
|
|
27
|
+
(#2107): a row with one scope column set and the other null matches no principal's
|
|
28
|
+
auth and is unreadable by everyone except a superadmin. Such a row is therefore
|
|
29
|
+
*dead data* — it can only ever leak through a future code path that reads or
|
|
30
|
+
exports attachments **without** going through `checkAttachmentAccess` (a new export
|
|
31
|
+
endpoint, webhook delivery, OCR worker, or migration backfill). That is exactly the
|
|
32
|
+
fail-open class the access fix closed at read time; the creation guard closes it at
|
|
33
|
+
write time.
|
|
34
|
+
|
|
35
|
+
## Always
|
|
36
|
+
|
|
37
|
+
- **MUST call `assertAttachmentScopeInvariant({ tenantId, organizationId })` from
|
|
38
|
+
`lib/access.ts` before persisting any new `Attachment` row.** It throws on a
|
|
39
|
+
partial-null scope and accepts both fully-scoped and fully-global rows. The
|
|
40
|
+
attachments upload route (`api/route.ts`) already guards its creation site.
|
|
41
|
+
- **MUST gate every attachment read through `checkAttachmentAccess`** (`lib/access.ts`)
|
|
42
|
+
so tenant scoping and partition visibility are enforced consistently.
|
|
43
|
+
- When copying/cloning attachments across records, **carry the source row's scope
|
|
44
|
+
pair as a unit** (both columns together) rather than overriding one column with a
|
|
45
|
+
possibly-null value.
|
|
46
|
+
|
|
47
|
+
## Never
|
|
48
|
+
|
|
49
|
+
- **Never create a partial-null attachment** (one scope column set, the other null).
|
|
50
|
+
- **Never read or expose attachment rows without `checkAttachmentAccess`** — bypassing
|
|
51
|
+
it reintroduces the cross-tenant fail-open class.
|
|
52
|
+
|
|
53
|
+
## Known cross-module creation paths
|
|
54
|
+
|
|
55
|
+
These paths create `Attachment` rows from other modules and must preserve the
|
|
56
|
+
both-or-neither invariant (audited for #2109):
|
|
57
|
+
|
|
58
|
+
- `packages/core/src/modules/attachments/api/route.ts` — primary upload; scope comes
|
|
59
|
+
from authenticated request context (both set). **Guarded.**
|
|
60
|
+
- `packages/core/src/modules/sync_excel/lib/upload-storage.ts` — both scopes are
|
|
61
|
+
required inputs (type-enforced). Safe.
|
|
62
|
+
- `packages/core/src/modules/catalog/seed/examples.ts` — both scopes required on
|
|
63
|
+
`SeedScope`. Safe.
|
|
64
|
+
- `packages/core/src/modules/catalog/commands/variants.ts` — clones variant media to
|
|
65
|
+
the product; inherits the source/variant scope pair (`?? null` only collapses to
|
|
66
|
+
the global both-null shape).
|
|
67
|
+
- `packages/core/src/modules/messages/lib/attachments.ts`
|
|
68
|
+
(`copyAttachmentsForForwardMessages`) — copies forwarded message attachments and
|
|
69
|
+
accepts a nullable `targetOrganizationId` with a non-null `tenantId`. This is the
|
|
70
|
+
one path that can construct a partial-null row; copy the **source attachment's**
|
|
71
|
+
scope pair when wiring new callers, and apply the creation guard if this path is
|
|
72
|
+
refactored.
|
|
73
|
+
|
|
74
|
+
## Validation Commands
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
yarn workspace @open-mercato/core test -- access
|
|
78
|
+
yarn workspace @open-mercato/core build
|
|
79
|
+
```
|
|
@@ -12,6 +12,7 @@ import { requestOcrProcessing } from '../lib/ocrQueue'
|
|
|
12
12
|
import { StorageDriverFactory } from '../lib/drivers'
|
|
13
13
|
import { OcrService, shouldUseLlmOcr } from '../lib/ocrService'
|
|
14
14
|
import { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'
|
|
15
|
+
import { assertAttachmentScopeInvariant } from '../lib/access'
|
|
15
16
|
import {
|
|
16
17
|
mergeAttachmentMetadata,
|
|
17
18
|
normalizeAttachmentAssignments,
|
|
@@ -421,6 +422,7 @@ export async function POST(req: Request) {
|
|
|
421
422
|
}
|
|
422
423
|
const metadata = mergeAttachmentMetadata(null, { assignments, tags })
|
|
423
424
|
const attachmentId = randomUUID()
|
|
425
|
+
assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
424
426
|
const att = em.create(Attachment, {
|
|
425
427
|
id: attachmentId,
|
|
426
428
|
entityId,
|
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
2
2
|
import type { Attachment, AttachmentPartition } from '../data/entities'
|
|
3
3
|
|
|
4
|
+
export type AttachmentScope = {
|
|
5
|
+
tenantId?: string | null
|
|
6
|
+
organizationId?: string | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeScopeValue(value: string | null | undefined): string | null {
|
|
10
|
+
if (typeof value !== 'string') return null
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
return trimmed.length > 0 ? trimmed : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enforce the attachments scope invariant at every creation boundary:
|
|
17
|
+
* an attachment is either fully **global** (both `tenant_id` and
|
|
18
|
+
* `organization_id` null) or fully **scoped** (both set) — never partial.
|
|
19
|
+
*
|
|
20
|
+
* `isSameScope` deliberately treats a partial-null row as inaccessible to
|
|
21
|
+
* every non-superadmin principal (fail-closed, #2107), so a partial-null row
|
|
22
|
+
* is dead data that can only ever leak through a future code path that skips
|
|
23
|
+
* the access check. Guarding creation keeps that class of fail-open bug from
|
|
24
|
+
* re-emerging (#2109). Call this before persisting any `Attachment`.
|
|
25
|
+
*/
|
|
26
|
+
export function assertAttachmentScopeInvariant(scope: AttachmentScope): void {
|
|
27
|
+
const tenantId = normalizeScopeValue(scope.tenantId)
|
|
28
|
+
const organizationId = normalizeScopeValue(scope.organizationId)
|
|
29
|
+
const tenantSet = tenantId !== null
|
|
30
|
+
const organizationSet = organizationId !== null
|
|
31
|
+
if (tenantSet !== organizationSet) {
|
|
32
|
+
const missing = tenantSet ? 'organization_id' : 'tenant_id'
|
|
33
|
+
throw new Error(
|
|
34
|
+
`[internal] Attachment scope invariant violated: ${missing} is null while the other scope column is set. ` +
|
|
35
|
+
'Attachments must be either fully scoped (both tenant_id and organization_id) or fully global (both null).',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
export function isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {
|
|
5
41
|
if (!auth) return false
|
|
6
42
|
if ((auth as any).isSuperAdmin === true) return true
|
|
@@ -96,6 +96,7 @@ export class ActionLog {
|
|
|
96
96
|
@Entity({ tableName: 'access_logs' })
|
|
97
97
|
@Index({ name: 'access_logs_tenant_idx', properties: ['tenantId', 'createdAt'] })
|
|
98
98
|
@Index({ name: 'access_logs_actor_idx', properties: ['actorUserId', 'createdAt'] })
|
|
99
|
+
@Index({ name: 'access_logs_created_at_idx', properties: ['createdAt'] })
|
|
99
100
|
export class AccessLog {
|
|
100
101
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
101
102
|
id!: string
|
|
@@ -208,6 +208,16 @@
|
|
|
208
208
|
"primary": false,
|
|
209
209
|
"unique": false
|
|
210
210
|
},
|
|
211
|
+
{
|
|
212
|
+
"keyName": "access_logs_created_at_idx",
|
|
213
|
+
"columnNames": [
|
|
214
|
+
"created_at"
|
|
215
|
+
],
|
|
216
|
+
"composite": false,
|
|
217
|
+
"constraint": false,
|
|
218
|
+
"primary": false,
|
|
219
|
+
"unique": false
|
|
220
|
+
},
|
|
211
221
|
{
|
|
212
222
|
"keyName": "access_logs_pkey",
|
|
213
223
|
"columnNames": [
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260611104500 extends Migration {
|
|
4
|
+
|
|
5
|
+
override up(): void | Promise<void> {
|
|
6
|
+
this.addSql(`create index "access_logs_created_at_idx" on "access_logs" ("created_at");`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
override down(): void | Promise<void> {
|
|
10
|
+
this.addSql(`drop index "access_logs_created_at_idx";`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|
|
@@ -20,10 +20,23 @@ function toPositiveNumber(value: string | undefined, fallback: number): number {
|
|
|
20
20
|
return parsed
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function toNonNegativeNumber(value: string | undefined, fallback: number): number {
|
|
24
|
+
if (value === undefined || value === '') return fallback
|
|
25
|
+
const parsed = Number(value)
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed < 0) return fallback
|
|
27
|
+
return parsed
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
const CORE_RETENTION_DAYS = toPositiveNumber(process.env.AUDIT_LOGS_CORE_RETENTION_DAYS, 7)
|
|
24
31
|
const NON_CORE_RETENTION_HOURS = toPositiveNumber(process.env.AUDIT_LOGS_NON_CORE_RETENTION_HOURS, 8)
|
|
25
32
|
const CORE_RETENTION_MS = CORE_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
26
33
|
const NON_CORE_RETENTION_MS = NON_CORE_RETENTION_HOURS * 60 * 60 * 1000
|
|
34
|
+
// Rotation runs after every successful write; without a gate that means two
|
|
35
|
+
// DELETE statements per CRUD GET. Amortize to one rotation per interval per
|
|
36
|
+
// process — `0` opts back into rotate-on-every-write (test harnesses).
|
|
37
|
+
const ROTATE_INTERVAL_MS = toNonNegativeNumber(process.env.AUDIT_LOGS_ROTATE_INTERVAL_MS, 60_000)
|
|
38
|
+
|
|
39
|
+
let lastRotatedAt: number | null = null
|
|
27
40
|
const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
|
|
28
41
|
// Postgres has a hard limit of 65k bind parameters per statement. Each access
|
|
29
42
|
// log row uses 10 bind values (see INSERT below), so 500 rows × 10 = 5 000
|
|
@@ -380,6 +393,8 @@ export class AccessLogService {
|
|
|
380
393
|
|
|
381
394
|
private async rotate(fork: EntityManager) {
|
|
382
395
|
const now = Date.now()
|
|
396
|
+
if (ROTATE_INTERVAL_MS > 0 && lastRotatedAt !== null && now - lastRotatedAt < ROTATE_INTERVAL_MS) return
|
|
397
|
+
lastRotatedAt = now
|
|
383
398
|
const coreCutoff = new Date(now - CORE_RETENTION_MS)
|
|
384
399
|
const nonCoreCutoff = new Date(now - NON_CORE_RETENTION_MS)
|
|
385
400
|
try {
|
|
@@ -69,6 +69,13 @@ const sectionGroupSchema = z.object({
|
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
const adminNavResponseSchema = z.object({
|
|
72
|
+
brand: z.object({
|
|
73
|
+
name: z.string().optional(),
|
|
74
|
+
logo: z.object({
|
|
75
|
+
src: z.string(),
|
|
76
|
+
alt: z.string().optional(),
|
|
77
|
+
}).nullable().optional(),
|
|
78
|
+
}).nullable().optional(),
|
|
72
79
|
groups: z.array(
|
|
73
80
|
z.object({
|
|
74
81
|
id: z.string().optional(),
|
|
@@ -160,6 +167,8 @@ export async function GET(req: Request) {
|
|
|
160
167
|
`nav:entities:${cacheScopeTenantId || 'null'}`,
|
|
161
168
|
`nav:locale:${locale}`,
|
|
162
169
|
`nav:sidebar:user:${auth.sub}`,
|
|
170
|
+
cacheScopeTenantId ? `nav:sidebar:tenant:${cacheScopeTenantId}` : undefined,
|
|
171
|
+
cacheScopeOrganizationId ? `nav:sidebar:organization:${cacheScopeOrganizationId}` : undefined,
|
|
163
172
|
`nav:sidebar:scope:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}:${locale}`,
|
|
164
173
|
...((Array.isArray(auth.roles) ? auth.roles : []).map((role) => `nav:sidebar:role:${role}`)),
|
|
165
174
|
].filter(Boolean) as string[]
|
|
@@ -108,21 +108,21 @@ export async function POST(req: Request) {
|
|
|
108
108
|
user = await auth.findUserByEmailAndTenant(parsed.data.email, tenantId)
|
|
109
109
|
} else {
|
|
110
110
|
const users = await auth.findUsersByEmail(parsed.data.email)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
user = users[0] ?? null
|
|
118
|
-
}
|
|
119
|
-
if (!user || !user.passwordHash) {
|
|
120
|
-
void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason: 'invalid_credentials' }).catch(() => undefined)
|
|
121
|
-
return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
|
|
111
|
+
// Never disclose that an email is registered across multiple tenants — a
|
|
112
|
+
// password-independent 400-vs-401 response is an account/topology oracle
|
|
113
|
+
// (issue #2242). Treat an ambiguous match as no resolvable user and fall
|
|
114
|
+
// through to the uniform invalid-credentials path; tenant-selection
|
|
115
|
+
// guidance is delivered out-of-band via the activation/login link.
|
|
116
|
+
user = users.length === 1 ? users[0] : null
|
|
122
117
|
}
|
|
118
|
+
// Always verify the password — verifyPassword runs a constant-time bcrypt
|
|
119
|
+
// comparison even when the user is missing or has no hash — so unknown-email,
|
|
120
|
+
// wrong-password, and multi-tenant cases return an identical 401 with
|
|
121
|
+
// identical latency.
|
|
123
122
|
const ok = await auth.verifyPassword(user, parsed.data.password)
|
|
124
|
-
if (!ok) {
|
|
125
|
-
|
|
123
|
+
if (!user || !ok) {
|
|
124
|
+
const reason = user?.passwordHash ? 'invalid_password' : 'invalid_credentials'
|
|
125
|
+
void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)
|
|
126
126
|
return NextResponse.json({ ok: false, error: translate('auth.login.errors.invalidCredentials', 'Invalid email or password') }, { status: 401 })
|
|
127
127
|
}
|
|
128
128
|
// Optional role requirement
|
|
@@ -178,6 +178,8 @@ export class SidebarVariant {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
@Entity({ tableName: 'user_roles' })
|
|
181
|
+
@Index({ name: 'user_roles_user_id_idx', properties: ['user'] })
|
|
182
|
+
@Index({ name: 'user_roles_role_id_idx', properties: ['role'] })
|
|
181
183
|
export class UserRole {
|
|
182
184
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
183
185
|
id!: string
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"auth.login.errors.invalidCredentials": "Ungültige E-Mail oder ungültiges Passwort",
|
|
44
44
|
"auth.login.errors.permissionDenied": "Du hast keine Berechtigung, auf diesen Bereich zuzugreifen. Bitte wende dich an deine Administration.",
|
|
45
45
|
"auth.login.errors.tenantInvalid": "Tenant nicht gefunden. Entferne die Tenant-Auswahl und versuche es erneut.",
|
|
46
|
-
"auth.login.errors.tenantRequired": "Nutze den Login-Link aus deiner Tenant-Aktivierung, um fortzufahren.",
|
|
47
46
|
"auth.login.featureDenied": "Du hast keinen Zugriff auf diese Funktion ({feature}). Bitte wende dich an deine Administration.",
|
|
48
47
|
"auth.login.forgotPassword": "Passwort vergessen?",
|
|
49
48
|
"auth.login.loading": "Wird geladen ...",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"auth.login.errors.invalidCredentials": "Invalid email or password",
|
|
44
44
|
"auth.login.errors.permissionDenied": "You do not have permission to access this area. Please contact your administrator.",
|
|
45
45
|
"auth.login.errors.tenantInvalid": "Tenant not found. Clear the tenant selection and try again.",
|
|
46
|
-
"auth.login.errors.tenantRequired": "Use the login link provided with your tenant activation to continue.",
|
|
47
46
|
"auth.login.featureDenied": "You don't have access to this feature ({feature}). Please contact your administrator.",
|
|
48
47
|
"auth.login.forgotPassword": "Forgot password?",
|
|
49
48
|
"auth.login.loading": "Loading...",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"auth.login.errors.invalidCredentials": "Correo electrónico o contraseña no válidos",
|
|
44
44
|
"auth.login.errors.permissionDenied": "No tienes permiso para acceder a esta área. Ponte en contacto con tu administrador.",
|
|
45
45
|
"auth.login.errors.tenantInvalid": "No se encontró el inquilino. Borra la selección e inténtalo de nuevo.",
|
|
46
|
-
"auth.login.errors.tenantRequired": "Usa el enlace de inicio de sesión proporcionado con la activación de tu inquilino para continuar.",
|
|
47
46
|
"auth.login.featureDenied": "No tienes acceso a esta funcionalidad ({feature}). Ponte en contacto con tu administrador.",
|
|
48
47
|
"auth.login.forgotPassword": "¿Olvidaste tu contraseña?",
|
|
49
48
|
"auth.login.loading": "Cargando...",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"auth.login.errors.invalidCredentials": "Nieprawidłowy email lub hasło",
|
|
44
44
|
"auth.login.errors.permissionDenied": "Nie masz uprawnień do tego obszaru. Skontaktuj się z administratorem.",
|
|
45
45
|
"auth.login.errors.tenantInvalid": "Nie znaleziono najemcy. Wyczyść wybór i spróbuj ponownie.",
|
|
46
|
-
"auth.login.errors.tenantRequired": "Użyj linku logowania z aktywacji najemcy, aby kontynuować.",
|
|
47
46
|
"auth.login.featureDenied": "Nie masz dostępu do tej funkcji ({feature}). Skontaktuj się z administratorem.",
|
|
48
47
|
"auth.login.forgotPassword": "Nie pamiętasz hasła?",
|
|
49
48
|
"auth.login.loading": "Ładowanie...",
|
|
@@ -22,9 +22,15 @@ import { resolveRegisteredLucideIconNode } from '@open-mercato/ui/backend/icons/
|
|
|
22
22
|
import { profilePathPrefixes, profileSections } from './profile-sections'
|
|
23
23
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
24
24
|
import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getSelectedOrganizationFromRequest,
|
|
27
|
+
resolveFeatureCheckContext,
|
|
28
|
+
} from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
29
|
+
import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
|
|
30
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
26
31
|
import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities'
|
|
27
32
|
import { Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
33
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
28
34
|
import {
|
|
29
35
|
applySidebarPreference,
|
|
30
36
|
loadFirstRoleSidebarPreference,
|
|
@@ -397,6 +403,35 @@ export async function resolveBackendChromePayload({
|
|
|
397
403
|
),
|
|
398
404
|
)
|
|
399
405
|
|
|
406
|
+
const requestOrganizationId = request ? getSelectedOrganizationFromRequest(request) : null
|
|
407
|
+
const fallbackOrganizationId = selectedOrganizationId ?? requestOrganizationId ?? auth.orgId ?? null
|
|
408
|
+
const brandOrganizationId = scopedOrganizationId
|
|
409
|
+
?? (fallbackOrganizationId && !isAllOrganizationsSelection(fallbackOrganizationId) ? fallbackOrganizationId : null)
|
|
410
|
+
|
|
411
|
+
let brand: BackendChromePayload['brand'] = null
|
|
412
|
+
if (brandOrganizationId && scopedTenantId) {
|
|
413
|
+
try {
|
|
414
|
+
const organization = await findOneWithDecryption(
|
|
415
|
+
em,
|
|
416
|
+
Organization,
|
|
417
|
+
{ id: brandOrganizationId, tenant: scopedTenantId, deletedAt: null },
|
|
418
|
+
undefined,
|
|
419
|
+
{ tenantId: scopedTenantId, organizationId: brandOrganizationId },
|
|
420
|
+
)
|
|
421
|
+
if (organization?.logoUrl) {
|
|
422
|
+
brand = {
|
|
423
|
+
name: organization.name,
|
|
424
|
+
logo: {
|
|
425
|
+
src: organization.logoUrl,
|
|
426
|
+
alt: `${organization.name} logo`,
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
brand = null
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
400
435
|
return {
|
|
401
436
|
groups: appliedGroups.map(({ weight: _weight, ...group }) => group),
|
|
402
437
|
settingsSections,
|
|
@@ -405,5 +440,6 @@ export async function resolveBackendChromePayload({
|
|
|
405
440
|
profilePathPrefixes,
|
|
406
441
|
grantedFeatures,
|
|
407
442
|
roles: Array.isArray(auth.roles) ? auth.roles : [],
|
|
443
|
+
brand,
|
|
408
444
|
}
|
|
409
445
|
}
|
|
@@ -14,18 +14,21 @@ const DEV_ONLY_SECRET = 'om-consent-integrity-dev-only-secret'
|
|
|
14
14
|
let missingSecretWarned = false
|
|
15
15
|
|
|
16
16
|
function getSecret(): string {
|
|
17
|
-
const secret = process.env.CONSENT_INTEGRITY_SECRET
|
|
17
|
+
const secret = process.env.CONSENT_INTEGRITY_SECRET
|
|
18
|
+
|| process.env.AUTH_SECRET
|
|
19
|
+
|| process.env.NEXTAUTH_SECRET
|
|
20
|
+
|| process.env.JWT_SECRET
|
|
18
21
|
if (!secret) {
|
|
19
22
|
if (process.env.NODE_ENV === 'production') {
|
|
20
23
|
throw new Error(
|
|
21
|
-
'[consentIntegrity] No CONSENT_INTEGRITY_SECRET/NEXTAUTH_SECRET set. ' +
|
|
24
|
+
'[consentIntegrity] No CONSENT_INTEGRITY_SECRET/AUTH_SECRET/NEXTAUTH_SECRET/JWT_SECRET set. ' +
|
|
22
25
|
'Refusing to compute or verify consent integrity hashes in production without a real secret.',
|
|
23
26
|
)
|
|
24
27
|
}
|
|
25
28
|
if (!missingSecretWarned) {
|
|
26
29
|
missingSecretWarned = true
|
|
27
30
|
console.warn(
|
|
28
|
-
'[consentIntegrity] No CONSENT_INTEGRITY_SECRET/NEXTAUTH_SECRET set — ' +
|
|
31
|
+
'[consentIntegrity] No CONSENT_INTEGRITY_SECRET/AUTH_SECRET/NEXTAUTH_SECRET/JWT_SECRET set — ' +
|
|
29
32
|
'using insecure dev-only default. Set a secret before deploying to production.',
|
|
30
33
|
)
|
|
31
34
|
}
|
|
@@ -1732,6 +1732,26 @@
|
|
|
1732
1732
|
}
|
|
1733
1733
|
},
|
|
1734
1734
|
"indexes": [
|
|
1735
|
+
{
|
|
1736
|
+
"keyName": "user_roles_user_id_idx",
|
|
1737
|
+
"columnNames": [
|
|
1738
|
+
"user_id"
|
|
1739
|
+
],
|
|
1740
|
+
"composite": false,
|
|
1741
|
+
"constraint": false,
|
|
1742
|
+
"primary": false,
|
|
1743
|
+
"unique": false
|
|
1744
|
+
},
|
|
1745
|
+
{
|
|
1746
|
+
"keyName": "user_roles_role_id_idx",
|
|
1747
|
+
"columnNames": [
|
|
1748
|
+
"role_id"
|
|
1749
|
+
],
|
|
1750
|
+
"composite": false,
|
|
1751
|
+
"constraint": false,
|
|
1752
|
+
"primary": false,
|
|
1753
|
+
"unique": false
|
|
1754
|
+
},
|
|
1735
1755
|
{
|
|
1736
1756
|
"keyName": "user_roles_pkey",
|
|
1737
1757
|
"columnNames": [
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
// #2966: user_roles carries only its FK constraints and Postgres does not
|
|
4
|
+
// auto-index FK columns, so RBAC scans it sequentially by user_id on every
|
|
5
|
+
// ACL cache miss (rbacService super-admin check + ACL aggregation) and by
|
|
6
|
+
// role_id on user-list filtering and role rename/delete guards. Index both
|
|
7
|
+
// FK columns so these hot paths become index scans. The table is small
|
|
8
|
+
// relative to search_tokens, so a plain (transactional) build is safe.
|
|
9
|
+
export class Migration20260611103000 extends Migration {
|
|
10
|
+
|
|
11
|
+
override up(): void | Promise<void> {
|
|
12
|
+
this.addSql(`create index if not exists "user_roles_user_id_idx" on "user_roles" ("user_id");`);
|
|
13
|
+
this.addSql(`create index if not exists "user_roles_role_id_idx" on "user_roles" ("role_id");`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override down(): void | Promise<void> {
|
|
17
|
+
this.addSql(`drop index if exists "user_roles_user_id_idx";`);
|
|
18
|
+
this.addSql(`drop index if exists "user_roles_role_id_idx";`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
}
|
|
@@ -5,6 +5,13 @@ import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/email
|
|
|
5
5
|
import { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'
|
|
6
6
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
7
|
|
|
8
|
+
// A fixed, valid bcrypt hash (cost 10) of a throwaway value no real password
|
|
9
|
+
// can match. verifyPassword compares against it whenever the user is missing or
|
|
10
|
+
// has no password hash, so a failed login spends the same bcrypt CPU time
|
|
11
|
+
// regardless of whether the account exists — closing the timing side channel
|
|
12
|
+
// for account enumeration (issue #2242).
|
|
13
|
+
const TIMING_EQUALIZER_PASSWORD_HASH = '$2b$10$OcZrhmZpIzJOjkfwUrk7d.Nl0eHNzOvalBcBlt5Ran.4lj8R3HZg6'
|
|
14
|
+
|
|
8
15
|
export class AuthService {
|
|
9
16
|
constructor(private em: EntityManager) {}
|
|
10
17
|
|
|
@@ -48,9 +55,13 @@ export class AuthService {
|
|
|
48
55
|
)
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
async verifyPassword(user: User, password: string) {
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
async verifyPassword(user: User | null, password: string) {
|
|
59
|
+
const storedHash = user?.passwordHash ?? null
|
|
60
|
+
// Always run a bcrypt comparison — against a fixed dummy hash when the user
|
|
61
|
+
// is absent or has no password — so login latency does not reveal whether
|
|
62
|
+
// the account exists (timing-based enumeration, issue #2242).
|
|
63
|
+
const matched = await compare(password, storedHash ?? TIMING_EQUALIZER_PASSWORD_HASH)
|
|
64
|
+
return storedHash !== null && matched
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
async updateLastLoginAt(user: User) {
|
|
@@ -70,7 +81,16 @@ export class AuthService {
|
|
|
70
81
|
{ populate: ['role'] },
|
|
71
82
|
{ tenantId: resolvedTenantId, organizationId: user.organizationId ?? null },
|
|
72
83
|
)
|
|
73
|
-
|
|
84
|
+
// A populated `role` can still be null when the link points at a soft-deleted
|
|
85
|
+
// role (the Role soft-delete filter suppresses hydration), e.g. an admin link
|
|
86
|
+
// orphaned by a re-seed during interrupted-provisioning recovery. Dropping such
|
|
87
|
+
// links keeps role resolution from throwing on the login / session-refresh hot
|
|
88
|
+
// path, mirroring resolveCanonicalStaffAuthContext in lib/sessionIntegrity.ts.
|
|
89
|
+
return links
|
|
90
|
+
.map((l) => l.role)
|
|
91
|
+
.filter((role): role is Role => !!role)
|
|
92
|
+
.map((role) => role.name)
|
|
93
|
+
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
|
|
@@ -4,6 +4,7 @@ import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
|
|
|
4
4
|
import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
5
5
|
import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
|
|
6
6
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import { buildOrgScopeUserCacheTag, buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
8
|
import { matchFeature as sharedMatchFeature, hasAllFeatures as sharedHasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
|
|
8
9
|
import { filterGrantsByEnabledModules, getOwningModuleId, getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
9
10
|
|
|
@@ -130,7 +131,12 @@ export class RbacService {
|
|
|
130
131
|
*/
|
|
131
132
|
async invalidateUserCache(userId: string): Promise<void> {
|
|
132
133
|
this.globalSuperAdminCache.delete(userId)
|
|
133
|
-
|
|
134
|
+
// Also drop the directory OrganizationScope cache for this user. That scope's
|
|
135
|
+
// accessible-org set is derived from this user's ACL/role grants, so any
|
|
136
|
+
// permission change that invalidates the RBAC cache must invalidate the
|
|
137
|
+
// resolved scope too. This is the missing `org-scope:user:*` caller required
|
|
138
|
+
// before the cross-request scope TTL can be safely enabled (issue #2259).
|
|
139
|
+
await this.deleteCacheByTags([this.getUserTag(userId), buildOrgScopeUserCacheTag(userId)])
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
/**
|
|
@@ -142,7 +148,10 @@ export class RbacService {
|
|
|
142
148
|
*/
|
|
143
149
|
async invalidateTenantCache(tenantId: string): Promise<void> {
|
|
144
150
|
this.globalSuperAdminCache.clear()
|
|
145
|
-
|
|
151
|
+
// Role ACL changes invalidate every user in the tenant; the resolved
|
|
152
|
+
// OrganizationScope for those users derives from the same grants, so drop
|
|
153
|
+
// the tenant-tagged scope entries alongside the RBAC ones (issue #2259).
|
|
154
|
+
await this.deleteCacheByTags([this.getTenantTag(tenantId), buildOrgScopeTenantCacheTag(tenantId)], [tenantId])
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
/**
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { AlertTriangle } from 'lucide-react'
|
|
5
4
|
import { Alert, AlertTitle, AlertDescription } from '@open-mercato/ui/primitives/alert'
|
|
6
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
6
|
import type { DomainMappingRow } from './types'
|
|
@@ -15,7 +14,6 @@ export function DnsDiagnostics({ mapping }: DiagnosticsProps) {
|
|
|
15
14
|
if (mapping.status !== 'dns_failed') return null
|
|
16
15
|
return (
|
|
17
16
|
<Alert variant="destructive">
|
|
18
|
-
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
19
17
|
<AlertTitle>
|
|
20
18
|
{t('customer_accounts.domainMapping.dns.diagnostics.title', 'DNS configuration issue')}
|
|
21
19
|
</AlertTitle>
|
|
@@ -41,7 +39,6 @@ export function TlsDiagnostics({ mapping }: DiagnosticsProps) {
|
|
|
41
39
|
if (mapping.status !== 'tls_failed') return null
|
|
42
40
|
return (
|
|
43
41
|
<Alert variant="warning">
|
|
44
|
-
<AlertTriangle className="h-4 w-4" aria-hidden />
|
|
45
42
|
<AlertTitle>
|
|
46
43
|
{t('customer_accounts.domainMapping.tls.diagnostics.title', 'SSL certificate issue')}
|
|
47
44
|
</AlertTitle>
|