@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7749.1.ea3edf75e3
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/generated/entities/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/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/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { matchesEntity } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
|
|
2
|
+
import { attachmentAccessResolverSchema, protectedAttachmentTargetSchema } from '../data/validators'
|
|
3
|
+
import type {
|
|
4
|
+
AttachmentAccessAction,
|
|
5
|
+
AttachmentAccessRecord,
|
|
6
|
+
AttachmentAccessResolver,
|
|
7
|
+
AttachmentAccessResolverEntry,
|
|
8
|
+
AttachmentAccessTarget,
|
|
9
|
+
ProtectedAttachmentTarget,
|
|
10
|
+
} from './access-types'
|
|
11
|
+
|
|
12
|
+
const GLOBAL_KEY = '__openMercatoAttachmentAccessRegistry__'
|
|
13
|
+
|
|
14
|
+
type RegisteredResolver = AttachmentAccessResolver & {
|
|
15
|
+
moduleOrder: number
|
|
16
|
+
declarationOrder: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Registry = {
|
|
20
|
+
invalid: boolean
|
|
21
|
+
resolvers: readonly RegisteredResolver[]
|
|
22
|
+
protectedTargets: readonly ProtectedAttachmentTarget[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emptyRegistry: Registry = Object.freeze({ invalid: false, resolvers: [], protectedTargets: [] })
|
|
26
|
+
|
|
27
|
+
export function getAttachmentAccessRegistry(): Registry {
|
|
28
|
+
return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Registry | undefined ?? emptyRegistry
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerAttachmentAccessResolvers(entries: readonly AttachmentAccessResolverEntry[]): void {
|
|
32
|
+
const globalState = globalThis as Record<string, unknown>
|
|
33
|
+
globalState[GLOBAL_KEY] = { invalid: true, resolvers: [], protectedTargets: [] } satisfies Registry
|
|
34
|
+
const resolvers: RegisteredResolver[] = []
|
|
35
|
+
const protectedTargets: ProtectedAttachmentTarget[] = []
|
|
36
|
+
const ids = new Set<string>()
|
|
37
|
+
for (const [moduleOrder, entry] of entries.entries()) {
|
|
38
|
+
if (typeof entry.moduleId !== 'string' || !entry.moduleId || !Array.isArray(entry.resolvers)) {
|
|
39
|
+
throw new Error('[internal] Invalid attachment access registry entry')
|
|
40
|
+
}
|
|
41
|
+
for (const [declarationOrder, raw] of entry.resolvers.entries()) {
|
|
42
|
+
const resolver = attachmentAccessResolverSchema.parse(raw)
|
|
43
|
+
if (!resolver.id.startsWith(`${entry.moduleId}.`) || ids.has(resolver.id)) {
|
|
44
|
+
throw new Error('[internal] Attachment access resolver IDs must be unique and owned by their module')
|
|
45
|
+
}
|
|
46
|
+
ids.add(resolver.id)
|
|
47
|
+
resolvers.push(Object.freeze({
|
|
48
|
+
...resolver,
|
|
49
|
+
actions: resolver.actions ? Object.freeze([...resolver.actions]) : undefined,
|
|
50
|
+
moduleOrder,
|
|
51
|
+
declarationOrder,
|
|
52
|
+
}))
|
|
53
|
+
}
|
|
54
|
+
for (const raw of entry.protectedTargets ?? []) {
|
|
55
|
+
const target = protectedAttachmentTargetSchema.parse(raw)
|
|
56
|
+
if (!target.resolverId.startsWith(`${entry.moduleId}.`)) {
|
|
57
|
+
throw new Error('[internal] Attachment access requirements must name their module resolver')
|
|
58
|
+
}
|
|
59
|
+
protectedTargets.push(Object.freeze(target))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
resolvers.sort((left, right) => (
|
|
63
|
+
(left.priority ?? 50) - (right.priority ?? 50)
|
|
64
|
+
|| left.moduleOrder - right.moduleOrder
|
|
65
|
+
|| left.declarationOrder - right.declarationOrder
|
|
66
|
+
))
|
|
67
|
+
globalState[GLOBAL_KEY] = Object.freeze({
|
|
68
|
+
invalid: false,
|
|
69
|
+
resolvers: Object.freeze(resolvers),
|
|
70
|
+
protectedTargets: Object.freeze(protectedTargets),
|
|
71
|
+
}) satisfies Registry
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function matchesAttachmentAccessSelector(selector: string, value: string): boolean {
|
|
75
|
+
return matchesEntity(selector, value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function matchesAttachmentAccessTargets(selector: string, targets: readonly AttachmentAccessTarget[]): boolean {
|
|
79
|
+
return selector === '*' || targets.some((target) => matchesAttachmentAccessSelector(selector, target.entityId))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function matchesAttachmentAccessResolver(
|
|
83
|
+
resolver: AttachmentAccessResolver,
|
|
84
|
+
partitionCode: string,
|
|
85
|
+
targets: readonly AttachmentAccessTarget[],
|
|
86
|
+
action: AttachmentAccessAction,
|
|
87
|
+
): boolean {
|
|
88
|
+
return matchesAttachmentAccessSelector(resolver.targetPartition, partitionCode)
|
|
89
|
+
&& matchesAttachmentAccessTargets(resolver.targetEntity ?? '*', targets)
|
|
90
|
+
&& (!resolver.actions || resolver.actions.includes(action))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function collectAttachmentAccessTargets(attachment: AttachmentAccessRecord): readonly AttachmentAccessTarget[] {
|
|
98
|
+
const targets: AttachmentAccessTarget[] = []
|
|
99
|
+
function append(entityId: unknown, recordId: unknown, origin: AttachmentAccessTarget['origin']) {
|
|
100
|
+
if (typeof entityId !== 'string' || !entityId.trim()) return
|
|
101
|
+
const target = Object.freeze({
|
|
102
|
+
entityId: entityId.trim(),
|
|
103
|
+
recordId: typeof recordId === 'string' ? recordId.trim() : '',
|
|
104
|
+
origin,
|
|
105
|
+
})
|
|
106
|
+
if (!targets.some((candidate) => candidate.entityId === target.entityId && candidate.recordId === target.recordId)) {
|
|
107
|
+
targets.push(target)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
append(attachment.entityId, attachment.recordId, 'primary')
|
|
111
|
+
const assignments = isRecord(attachment.storageMetadata) ? attachment.storageMetadata.assignments : undefined
|
|
112
|
+
const entries = Array.isArray(assignments) ? assignments : isRecord(assignments) ? [assignments] : []
|
|
113
|
+
for (const assignment of entries) {
|
|
114
|
+
if (isRecord(assignment)) append(assignment.type, assignment.id, 'assignment')
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze(targets)
|
|
117
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
5
|
+
import type { Attachment, AttachmentPartition } from '../data/entities'
|
|
6
|
+
import { attachmentAccessDecisionSchema, attachmentAccessRequirementsSchema, attachmentAccessSubjectFeaturesSchema } from '../data/validators'
|
|
7
|
+
import { checkAttachmentAccess } from './access'
|
|
8
|
+
import {
|
|
9
|
+
collectAttachmentAccessTargets,
|
|
10
|
+
getAttachmentAccessRegistry,
|
|
11
|
+
matchesAttachmentAccessResolver,
|
|
12
|
+
matchesAttachmentAccessSelector,
|
|
13
|
+
matchesAttachmentAccessTargets,
|
|
14
|
+
} from './access-registry'
|
|
15
|
+
import type {
|
|
16
|
+
AttachmentAccessAction, AttachmentAccessDecision, AttachmentAccessPartition,
|
|
17
|
+
AttachmentAccessRecord, AttachmentAccessSubject,
|
|
18
|
+
} from './access-types'
|
|
19
|
+
|
|
20
|
+
const logger = createLogger('attachments').child({ component: 'access' })
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 1500
|
|
22
|
+
|
|
23
|
+
type RbacProjectionService = {
|
|
24
|
+
loadAcl(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{
|
|
25
|
+
isSuperAdmin: boolean; organizations: string[] | null
|
|
26
|
+
}>
|
|
27
|
+
getEffectiveFeatures(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<string[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type AttachmentAccessContext = {
|
|
31
|
+
container: AwilixContainer
|
|
32
|
+
subjects: Map<string, Promise<AttachmentAccessSubject>>
|
|
33
|
+
resolverCaches: Map<string, Map<string, unknown>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createAttachmentAccessContext(container: AwilixContainer): AttachmentAccessContext {
|
|
37
|
+
return { container, subjects: new Map(), resolverCaches: new Map() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AttachmentAccessResult = AttachmentAccessDecision & { protected: boolean }
|
|
41
|
+
|
|
42
|
+
class AccessTimeout extends Error {}
|
|
43
|
+
|
|
44
|
+
async function withinBudget<Result>(work: () => Promise<Result>, timeoutMs: number, controller: AbortController): Promise<Result> {
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([
|
|
48
|
+
Promise.resolve().then(work),
|
|
49
|
+
new Promise<never>((_resolve, reject) => {
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
controller.abort()
|
|
52
|
+
reject(new AccessTimeout('[internal] Attachment access evaluation timed out'))
|
|
53
|
+
}, timeoutMs)
|
|
54
|
+
}),
|
|
55
|
+
])
|
|
56
|
+
} finally {
|
|
57
|
+
if (timer) clearTimeout(timer)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function projectSubject(
|
|
62
|
+
context: AttachmentAccessContext,
|
|
63
|
+
auth: NonNullable<AuthContext>,
|
|
64
|
+
attachment: AttachmentAccessRecord,
|
|
65
|
+
): Promise<AttachmentAccessSubject> {
|
|
66
|
+
const scope = {
|
|
67
|
+
tenantId: attachment.tenantId ?? auth.tenantId ?? null,
|
|
68
|
+
organizationId: attachment.organizationId ?? auth.orgId ?? null,
|
|
69
|
+
}
|
|
70
|
+
const key = JSON.stringify([auth.sub, scope.tenantId, scope.organizationId])
|
|
71
|
+
let pending = context.subjects.get(key)
|
|
72
|
+
if (!pending) {
|
|
73
|
+
pending = (async () => {
|
|
74
|
+
const rbac = context.container.resolve<RbacProjectionService>('rbacService')
|
|
75
|
+
const acl = await rbac.loadAcl(auth.sub, scope)
|
|
76
|
+
const userFeatures = attachmentAccessSubjectFeaturesSchema.parse(await rbac.getEffectiveFeatures(auth.sub, scope))
|
|
77
|
+
if (typeof acl.isSuperAdmin !== 'boolean') {
|
|
78
|
+
throw new Error('[internal] Invalid attachment subject projection')
|
|
79
|
+
}
|
|
80
|
+
if (!acl.isSuperAdmin && acl.organizations && scope.organizationId
|
|
81
|
+
&& !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) {
|
|
82
|
+
throw new Error('[internal] Attachment subject is outside its current organization grants')
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
auth: Object.freeze({
|
|
86
|
+
sub: auth.sub, tenantId: scope.tenantId, orgId: scope.organizationId,
|
|
87
|
+
isApiKey: auth.isApiKey === true, userId: auth.userId, keyId: auth.keyId,
|
|
88
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
89
|
+
}),
|
|
90
|
+
...scope,
|
|
91
|
+
userFeatures: Object.freeze([...userFeatures]),
|
|
92
|
+
isSuperAdmin: acl.isSuperAdmin,
|
|
93
|
+
})
|
|
94
|
+
})()
|
|
95
|
+
context.subjects.set(key, pending)
|
|
96
|
+
}
|
|
97
|
+
return pending
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function evaluateAttachmentAccess(input: {
|
|
101
|
+
auth: AuthContext | undefined
|
|
102
|
+
attachment: AttachmentAccessRecord
|
|
103
|
+
partition: AttachmentAccessPartition
|
|
104
|
+
action: AttachmentAccessAction
|
|
105
|
+
context?: AttachmentAccessContext | null
|
|
106
|
+
requireAuthForPublic?: boolean
|
|
107
|
+
}): Promise<AttachmentAccessResult> {
|
|
108
|
+
const { auth, attachment, partition, action, context } = input
|
|
109
|
+
const baseline = checkAttachmentAccess(auth, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })
|
|
110
|
+
if (!baseline.ok) return { ok: false, status: baseline.status === 401 ? 401 : 403, reason: 'scope', protected: false }
|
|
111
|
+
const deny = (status: 401 | 403 | 404 | 504, reason: string, resolverId?: string): AttachmentAccessResult => {
|
|
112
|
+
logger.info('Attachment owner access denied', { resolverId, action, attachmentId: attachment.id, reason })
|
|
113
|
+
return { ok: false, status, reason, protected: true }
|
|
114
|
+
}
|
|
115
|
+
const registry = getAttachmentAccessRegistry()
|
|
116
|
+
if (registry.invalid) return deny(403, 'invalid_registry')
|
|
117
|
+
const parsedRequirements = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])
|
|
118
|
+
if (!parsedRequirements.success) return deny(403, 'invalid_requirements')
|
|
119
|
+
const targets = collectAttachmentAccessTargets(attachment)
|
|
120
|
+
const requirements = [...parsedRequirements.data, ...registry.protectedTargets.filter((target) => (
|
|
121
|
+
matchesAttachmentAccessSelector(target.targetPartition, partition.code)
|
|
122
|
+
))].filter((requirement) => matchesAttachmentAccessTargets(requirement.targetEntity, targets))
|
|
123
|
+
const resolvers = registry.resolvers.filter((resolver) => matchesAttachmentAccessResolver(resolver, partition.code, targets, action))
|
|
124
|
+
if (!requirements.length && !resolvers.length) return { ok: true, protected: false }
|
|
125
|
+
if (!auth) return deny(401, 'authentication_required')
|
|
126
|
+
for (const requirement of requirements) {
|
|
127
|
+
const requiredTargets = targets.filter((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId))
|
|
128
|
+
const provider = resolvers.find((resolver) => resolver.id === requirement.resolverId)
|
|
129
|
+
if (!provider || (requiredTargets.length
|
|
130
|
+
? requiredTargets.some((target) => !matchesAttachmentAccessSelector(provider.targetEntity ?? '*', target.entityId))
|
|
131
|
+
: (provider.targetEntity ?? '*') !== '*')) return deny(403, 'required_resolver_missing', requirement.resolverId)
|
|
132
|
+
if (targets.some((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId) && !target.recordId)) {
|
|
133
|
+
return deny(403, 'invalid_owner', requirement.resolverId)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!context) return deny(403, 'policy_context_missing')
|
|
137
|
+
let subject: AttachmentAccessSubject
|
|
138
|
+
try {
|
|
139
|
+
subject = await withinBudget(() => projectSubject(context, auth, attachment), DEFAULT_TIMEOUT_MS, new AbortController())
|
|
140
|
+
} catch (error) {
|
|
141
|
+
getTelemetryRuntime()?.reportError(new Error('[internal] Attachment subject projection failed'), {
|
|
142
|
+
module: 'attachments', code: 'attachments.subject_projection_failed', attributes: { attachmentId: attachment.id, action },
|
|
143
|
+
})
|
|
144
|
+
return deny(error instanceof AccessTimeout ? 504 : 403, 'subject_projection_failed')
|
|
145
|
+
}
|
|
146
|
+
const liveBaseline = checkAttachmentAccess({ ...auth, roles: [], isSuperAdmin: subject.isSuperAdmin }, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })
|
|
147
|
+
if (!liveBaseline.ok) return deny(403, 'current_scope')
|
|
148
|
+
for (const resolver of resolvers) {
|
|
149
|
+
const controller = new AbortController()
|
|
150
|
+
let cache = context.resolverCaches.get(resolver.id)
|
|
151
|
+
if (!cache) {
|
|
152
|
+
cache = new Map()
|
|
153
|
+
context.resolverCaches.set(resolver.id, cache)
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const raw = await withinBudget(() => resolver.resolve(Object.freeze({
|
|
157
|
+
action, subject,
|
|
158
|
+
attachment: Object.freeze({
|
|
159
|
+
id: attachment.id, entityId: attachment.entityId, recordId: attachment.recordId,
|
|
160
|
+
partitionCode: attachment.partitionCode, tenantId: attachment.tenantId,
|
|
161
|
+
organizationId: attachment.organizationId, fileName: attachment.fileName, mimeType: attachment.mimeType,
|
|
162
|
+
}),
|
|
163
|
+
partition: Object.freeze({
|
|
164
|
+
code: partition.code, isPublic: partition.isPublic,
|
|
165
|
+
tenantId: partition.tenantId, organizationId: partition.organizationId,
|
|
166
|
+
}),
|
|
167
|
+
targets, container: context.container, cache, signal: controller.signal,
|
|
168
|
+
})), resolver.timeoutMs ?? DEFAULT_TIMEOUT_MS, controller)
|
|
169
|
+
const decision = attachmentAccessDecisionSchema.parse(raw)
|
|
170
|
+
if (!decision.ok) return deny(decision.status, decision.reason, resolver.id)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
controller.abort()
|
|
173
|
+
getTelemetryRuntime()?.reportError(new Error(error instanceof AccessTimeout
|
|
174
|
+
? '[internal] Attachment resolver exceeded its time budget'
|
|
175
|
+
: '[internal] Attachment resolver failed or returned an invalid decision'), {
|
|
176
|
+
module: 'attachments', code: 'attachments.resolver_failed',
|
|
177
|
+
attributes: { resolverId: resolver.id, attachmentId: attachment.id, action },
|
|
178
|
+
})
|
|
179
|
+
return deny(error instanceof AccessTimeout ? 504 : 403, error instanceof AccessTimeout ? 'resolver_timeout' : 'resolver_failed', resolver.id)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, protected: true }
|
|
183
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
|
|
4
|
+
export type AttachmentAccessAction = 'read' | 'render' | 'metadata' | 'reassign' | 'delete' | 'export'
|
|
5
|
+
|
|
6
|
+
export type AttachmentAccessTarget = Readonly<{
|
|
7
|
+
entityId: string
|
|
8
|
+
recordId: string
|
|
9
|
+
origin: 'primary' | 'assignment'
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
export type AttachmentAccessRequirement = Readonly<{
|
|
13
|
+
resolverId: string
|
|
14
|
+
targetEntity: string
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export type ProtectedAttachmentTarget = AttachmentAccessRequirement & Readonly<{
|
|
18
|
+
targetPartition: string
|
|
19
|
+
}>
|
|
20
|
+
|
|
21
|
+
export type AttachmentAccessRecord = {
|
|
22
|
+
id: string
|
|
23
|
+
entityId: string
|
|
24
|
+
recordId: string
|
|
25
|
+
partitionCode: string
|
|
26
|
+
tenantId?: string | null
|
|
27
|
+
organizationId?: string | null
|
|
28
|
+
fileName: string
|
|
29
|
+
mimeType: string
|
|
30
|
+
storageMetadata?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AttachmentAccessPartition = {
|
|
34
|
+
code: string
|
|
35
|
+
isPublic: boolean
|
|
36
|
+
tenantId?: string | null
|
|
37
|
+
organizationId?: string | null
|
|
38
|
+
accessResolverRequirements?: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type AttachmentAccessSubject = Readonly<{
|
|
42
|
+
auth: Readonly<NonNullable<AuthContext>>
|
|
43
|
+
userFeatures: readonly string[]
|
|
44
|
+
tenantId: string | null
|
|
45
|
+
organizationId: string | null
|
|
46
|
+
isSuperAdmin: boolean
|
|
47
|
+
}>
|
|
48
|
+
|
|
49
|
+
export type AttachmentAccessInput = Readonly<{
|
|
50
|
+
action: AttachmentAccessAction
|
|
51
|
+
subject: AttachmentAccessSubject
|
|
52
|
+
attachment: Readonly<Omit<AttachmentAccessRecord, 'storageMetadata'>>
|
|
53
|
+
partition: Readonly<Omit<AttachmentAccessPartition, 'accessResolverRequirements'>>
|
|
54
|
+
targets: readonly AttachmentAccessTarget[]
|
|
55
|
+
container: AwilixContainer
|
|
56
|
+
cache: Map<string, unknown>
|
|
57
|
+
signal: AbortSignal
|
|
58
|
+
}>
|
|
59
|
+
|
|
60
|
+
export type AttachmentAccessDecision =
|
|
61
|
+
| { ok: true }
|
|
62
|
+
| { ok: false; status: 401 | 403 | 404 | 504; reason: string }
|
|
63
|
+
|
|
64
|
+
export type AttachmentAccessResolver = Readonly<{
|
|
65
|
+
id: string
|
|
66
|
+
targetPartition: string
|
|
67
|
+
targetEntity?: string
|
|
68
|
+
actions?: readonly AttachmentAccessAction[]
|
|
69
|
+
priority?: number
|
|
70
|
+
timeoutMs?: number
|
|
71
|
+
resolve(input: AttachmentAccessInput): Promise<AttachmentAccessDecision>
|
|
72
|
+
}>
|
|
73
|
+
|
|
74
|
+
export type AttachmentAccessResolverEntry = {
|
|
75
|
+
moduleId: string
|
|
76
|
+
resolvers: readonly unknown[]
|
|
77
|
+
protectedTargets?: readonly unknown[]
|
|
78
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
|
|
2
|
+
import { throwAttachmentAccessError } from './access-errors'
|
|
1
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
4
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
5
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
@@ -249,6 +251,7 @@ export class DefaultAttachmentService implements AttachmentService {
|
|
|
249
251
|
private readonly em: EntityManager,
|
|
250
252
|
private readonly storageDriverFactory: StorageDriverFactory,
|
|
251
253
|
private readonly resolveScopedUploadService?: (() => ScopedAttachmentUploadService | null) | null,
|
|
254
|
+
private readonly resolveAccessContext?: (() => AttachmentAccessContext) | null,
|
|
252
255
|
) {}
|
|
253
256
|
|
|
254
257
|
validateUpload(input: {
|
|
@@ -368,10 +371,11 @@ export class DefaultAttachmentService implements AttachmentService {
|
|
|
368
371
|
)
|
|
369
372
|
if (!partition) throw new CrudHttpError(500, { error: 'Attachment partition is not configured' })
|
|
370
373
|
|
|
371
|
-
const access =
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
374
|
+
const access = await evaluateAttachmentAccess({
|
|
375
|
+
auth: input.auth, attachment, partition, action: 'read',
|
|
376
|
+
context: this.resolveAccessContext?.(), requireAuthForPublic: true,
|
|
377
|
+
})
|
|
378
|
+
if (!access.ok) await throwAttachmentAccessError(access.status)
|
|
375
379
|
if (!partitionMatchesScope(partition, input.auth.tenantId, input.auth.orgId)) {
|
|
376
380
|
throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this scope' })
|
|
377
381
|
}
|
|
@@ -8,6 +8,7 @@ import { buildAttachmentFileUrl } from './imageUrls'
|
|
|
8
8
|
import { mergeAttachmentMetadata, upsertAssignment } from './metadata'
|
|
9
9
|
import { assertAttachmentScopeInvariant } from './access'
|
|
10
10
|
import { attachmentCrudEvents, attachmentCrudIndexer } from './crud'
|
|
11
|
+
import { ensureAttachmentPartitionProtection } from './access-protection'
|
|
11
12
|
|
|
12
13
|
/** DataEngine handle whose exact type is derived from `emitCrudSideEffects` (no `any`). */
|
|
13
14
|
type CrudDataEngine = Parameters<typeof emitCrudSideEffects>[0]['dataEngine']
|
|
@@ -58,6 +59,8 @@ export async function createAttachmentFromBuffer(input: CreateAttachmentFromBuff
|
|
|
58
59
|
}
|
|
59
60
|
if (!partition) throw new Error(`[internal] attachment partition "${code}" is not available`)
|
|
60
61
|
|
|
62
|
+
await ensureAttachmentPartitionProtection(em, partition)
|
|
63
|
+
|
|
61
64
|
const driver = await new StorageDriverFactory(em).resolveForPartition(partition.code, {
|
|
62
65
|
tenantId: input.tenantId,
|
|
63
66
|
organizationId: input.organizationId,
|
|
@@ -3,6 +3,7 @@ import { AttachmentPartition } from '../data/entities'
|
|
|
3
3
|
import { resolveDefaultAttachmentOcrEnabled } from './ocrConfig'
|
|
4
4
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
5
5
|
import { E } from '#generated/entities.ids.generated'
|
|
6
|
+
import { getDeclaredAttachmentAccessRequirements } from './access-protection'
|
|
6
7
|
|
|
7
8
|
export type AttachmentPartitionSeed = {
|
|
8
9
|
code: string
|
|
@@ -43,7 +44,6 @@ export async function ensureDefaultPartitions(em: EntityManager): Promise<void>
|
|
|
43
44
|
const existing = await repo.findAll({ fields: ['code'] })
|
|
44
45
|
const existingCodes = new Set(existing.map((entry) => entry.code))
|
|
45
46
|
const pending = DEFAULT_ATTACHMENT_PARTITIONS.filter((seed) => !existingCodes.has(seed.code))
|
|
46
|
-
if (!pending.length) return
|
|
47
47
|
for (const seed of pending) {
|
|
48
48
|
const record = repo.create({
|
|
49
49
|
code: seed.code,
|
|
@@ -51,11 +51,12 @@ export async function ensureDefaultPartitions(em: EntityManager): Promise<void>
|
|
|
51
51
|
description: seed.description ?? null,
|
|
52
52
|
storageDriver: 'local',
|
|
53
53
|
isPublic: seed.isPublic ?? false,
|
|
54
|
+
accessResolverRequirements: getDeclaredAttachmentAccessRequirements(seed.code),
|
|
54
55
|
requiresOcr: resolveDefaultAttachmentOcrEnabled(),
|
|
55
56
|
})
|
|
56
57
|
em.persist(record)
|
|
57
58
|
}
|
|
58
|
-
await em.flush()
|
|
59
|
+
if (pending.length) await em.flush()
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function sanitizePartitionCode(input: string): string {
|
|
@@ -8,6 +8,7 @@ import { Attachment, AttachmentPartition } from '../data/entities'
|
|
|
8
8
|
import { attachmentCrudEvents, attachmentCrudIndexer } from './crud'
|
|
9
9
|
import type { StorageDriverFactory } from './drivers'
|
|
10
10
|
import { buildAttachmentFileUrl } from './imageUrls'
|
|
11
|
+
import { ensureAttachmentPartitionProtection } from './access-protection'
|
|
11
12
|
import { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'
|
|
12
13
|
import { extractAttachmentContent } from './textExtraction'
|
|
13
14
|
import { requestOcrProcessing } from './ocrQueue'
|
|
@@ -142,6 +143,7 @@ export class ScopedAttachmentUploadService {
|
|
|
142
143
|
throw new ScopedAttachmentUploadError('partition_unavailable', 403)
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
await ensureAttachmentPartitionProtection(em, partition)
|
|
145
147
|
const driver = await storageDriverFactory.resolveForPartition(partition.code, {
|
|
146
148
|
tenantId: input.tenantId,
|
|
147
149
|
organizationId: input.organizationId,
|
|
@@ -2,7 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
2
2
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
3
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
4
|
import { Attachment, AttachmentPartition } from '../data/entities'
|
|
5
|
-
import {
|
|
5
|
+
import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
|
|
6
6
|
import { readAttachmentMetadata } from './metadata'
|
|
7
7
|
|
|
8
8
|
export type AttachmentRecordTarget = {
|
|
@@ -19,7 +19,10 @@ export type AttachmentTargetAccessInput = {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export class AttachmentTargetAccessService {
|
|
22
|
-
constructor(
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly em: EntityManager,
|
|
24
|
+
private readonly resolveAccessContext?: (() => AttachmentAccessContext) | null,
|
|
25
|
+
) {}
|
|
23
26
|
|
|
24
27
|
async canAccessLinkedTarget(input: AttachmentTargetAccessInput): Promise<boolean> {
|
|
25
28
|
if (!input.targets.length) return false
|
|
@@ -44,7 +47,11 @@ export class AttachmentTargetAccessService {
|
|
|
44
47
|
],
|
|
45
48
|
})
|
|
46
49
|
if (!partition) return false
|
|
47
|
-
|
|
50
|
+
const access = await evaluateAttachmentAccess({
|
|
51
|
+
auth: input.auth, attachment, partition, action: 'read',
|
|
52
|
+
context: this.resolveAccessContext?.(), requireAuthForPublic: true,
|
|
53
|
+
})
|
|
54
|
+
if (!access.ok) return false
|
|
48
55
|
|
|
49
56
|
const metadata = readAttachmentMetadata(attachment.storageMetadata)
|
|
50
57
|
return input.targets.some((target) => (
|
|
@@ -8,6 +8,23 @@
|
|
|
8
8
|
"name": "attachment_partitions",
|
|
9
9
|
"schema": "public",
|
|
10
10
|
"columns": {
|
|
11
|
+
"access_resolver_requirements": {
|
|
12
|
+
"name": "access_resolver_requirements",
|
|
13
|
+
"type": "jsonb",
|
|
14
|
+
"unsigned": false,
|
|
15
|
+
"autoincrement": false,
|
|
16
|
+
"primary": false,
|
|
17
|
+
"nullable": true,
|
|
18
|
+
"unique": false,
|
|
19
|
+
"length": null,
|
|
20
|
+
"precision": null,
|
|
21
|
+
"scale": null,
|
|
22
|
+
"default": null,
|
|
23
|
+
"comment": null,
|
|
24
|
+
"collation": null,
|
|
25
|
+
"enumItems": [],
|
|
26
|
+
"mappedType": "json"
|
|
27
|
+
},
|
|
11
28
|
"code": {
|
|
12
29
|
"name": "code",
|
|
13
30
|
"type": "text",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
export class Migration20261001120000_attachments extends Migration {
|
|
4
|
+
override up(): void {
|
|
5
|
+
this.addSql('alter table "attachment_partitions" add column if not exists "access_resolver_requirements" jsonb null;')
|
|
6
|
+
this.addSql(`
|
|
7
|
+
update attachment_partitions as partition
|
|
8
|
+
set access_resolver_requirements = coalesce(partition.access_resolver_requirements, '[]'::jsonb)
|
|
9
|
+
|| '[{"resolverId":"documents.document-attachments","targetEntity":"documents:document"}]'::jsonb,
|
|
10
|
+
updated_at = now()
|
|
11
|
+
where (partition.access_resolver_requirements is null or jsonb_typeof(partition.access_resolver_requirements) = 'array')
|
|
12
|
+
and not coalesce(partition.access_resolver_requirements, '[]'::jsonb)
|
|
13
|
+
@> '[{"resolverId":"documents.document-attachments","targetEntity":"documents:document"}]'::jsonb
|
|
14
|
+
and exists (
|
|
15
|
+
select 1 from attachments as attachment
|
|
16
|
+
where attachment.partition_code = partition.code
|
|
17
|
+
and (
|
|
18
|
+
btrim(attachment.entity_id) = 'documents:document'
|
|
19
|
+
or exists (
|
|
20
|
+
select 1
|
|
21
|
+
from jsonb_array_elements(
|
|
22
|
+
case jsonb_typeof(attachment.storage_metadata->'assignments')
|
|
23
|
+
when 'array' then attachment.storage_metadata->'assignments'
|
|
24
|
+
when 'object' then jsonb_build_array(attachment.storage_metadata->'assignments')
|
|
25
|
+
else '[]'::jsonb
|
|
26
|
+
end
|
|
27
|
+
) as assignment
|
|
28
|
+
where jsonb_typeof(assignment) = 'object'
|
|
29
|
+
and btrim(assignment->>'type') = 'documents:document'
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
import { syncAttachmentAccessProtection } from './lib/access-protection'
|
|
3
|
+
import { ensureDefaultPartitions } from './lib/partitions'
|
|
2
4
|
|
|
3
5
|
export const setup: ModuleSetupConfig = {
|
|
6
|
+
async onTenantCreated({ em }) {
|
|
7
|
+
await ensureDefaultPartitions(em)
|
|
8
|
+
await syncAttachmentAccessProtection(em)
|
|
9
|
+
},
|
|
10
|
+
async seedDefaults({ em }) {
|
|
11
|
+
await ensureDefaultPartitions(em)
|
|
12
|
+
await syncAttachmentAccessProtection(em)
|
|
13
|
+
},
|
|
4
14
|
defaultRoleFeatures: {
|
|
5
15
|
admin: ['attachments.*', 'attachments.view', 'attachments.manage'],
|
|
6
16
|
employee: ['attachments.view'],
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
"auth.acl.features.channel_resend.view": "View Resend channel configuration",
|
|
108
108
|
"auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
|
|
109
109
|
"auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
|
|
110
|
+
"auth.acl.features.channel_smtp.configure": "SMTP-Kanal-Standardeinstellungen konfigurieren",
|
|
111
|
+
"auth.acl.features.channel_smtp.view": "SMTP-Kanalkonfiguration anzeigen",
|
|
110
112
|
"auth.acl.features.checkout.create": "Create checkout links and templates",
|
|
111
113
|
"auth.acl.features.checkout.delete": "Delete checkout links and templates",
|
|
112
114
|
"auth.acl.features.checkout.edit": "Edit checkout links and templates",
|
|
@@ -442,6 +444,7 @@
|
|
|
442
444
|
"auth.acl.modules.channel_imap": "IMAP + SMTP Email Channel",
|
|
443
445
|
"auth.acl.modules.channel_resend": "Resend Email Channel",
|
|
444
446
|
"auth.acl.modules.channel_ses": "Amazon SES Email Channel",
|
|
447
|
+
"auth.acl.modules.channel_smtp": "SMTP-E-Mail-Kanal",
|
|
445
448
|
"auth.acl.modules.checkout": "Checkout",
|
|
446
449
|
"auth.acl.modules.communication_channels": "Communication Channels",
|
|
447
450
|
"auth.acl.modules.configs": "Configuration",
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
"auth.acl.features.channel_resend.view": "View Resend channel configuration",
|
|
108
108
|
"auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
|
|
109
109
|
"auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
|
|
110
|
+
"auth.acl.features.channel_smtp.configure": "Configure SMTP channel defaults",
|
|
111
|
+
"auth.acl.features.channel_smtp.view": "View SMTP channel configuration",
|
|
110
112
|
"auth.acl.features.checkout.create": "Create checkout links and templates",
|
|
111
113
|
"auth.acl.features.checkout.delete": "Delete checkout links and templates",
|
|
112
114
|
"auth.acl.features.checkout.edit": "Edit checkout links and templates",
|
|
@@ -442,6 +444,7 @@
|
|
|
442
444
|
"auth.acl.modules.channel_imap": "IMAP + SMTP Email Channel",
|
|
443
445
|
"auth.acl.modules.channel_resend": "Resend Email Channel",
|
|
444
446
|
"auth.acl.modules.channel_ses": "Amazon SES Email Channel",
|
|
447
|
+
"auth.acl.modules.channel_smtp": "SMTP Email Channel",
|
|
445
448
|
"auth.acl.modules.checkout": "Checkout",
|
|
446
449
|
"auth.acl.modules.communication_channels": "Communication Channels",
|
|
447
450
|
"auth.acl.modules.configs": "Configuration",
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
"auth.acl.features.channel_resend.view": "View Resend channel configuration",
|
|
108
108
|
"auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
|
|
109
109
|
"auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
|
|
110
|
+
"auth.acl.features.channel_smtp.configure": "Configurar los valores predeterminados del canal SMTP",
|
|
111
|
+
"auth.acl.features.channel_smtp.view": "Ver la configuración del canal SMTP",
|
|
110
112
|
"auth.acl.features.checkout.create": "Create checkout links and templates",
|
|
111
113
|
"auth.acl.features.checkout.delete": "Delete checkout links and templates",
|
|
112
114
|
"auth.acl.features.checkout.edit": "Edit checkout links and templates",
|
|
@@ -442,6 +444,7 @@
|
|
|
442
444
|
"auth.acl.modules.channel_imap": "IMAP + SMTP Email Channel",
|
|
443
445
|
"auth.acl.modules.channel_resend": "Resend Email Channel",
|
|
444
446
|
"auth.acl.modules.channel_ses": "Amazon SES Email Channel",
|
|
447
|
+
"auth.acl.modules.channel_smtp": "Canal de correo SMTP",
|
|
445
448
|
"auth.acl.modules.checkout": "Checkout",
|
|
446
449
|
"auth.acl.modules.communication_channels": "Communication Channels",
|
|
447
450
|
"auth.acl.modules.configs": "Configuration",
|
|
@@ -107,6 +107,8 @@
|
|
|
107
107
|
"auth.acl.features.channel_resend.view": "View Resend channel configuration",
|
|
108
108
|
"auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
|
|
109
109
|
"auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
|
|
110
|
+
"auth.acl.features.channel_smtp.configure": "SMTP 채널 기본값 구성",
|
|
111
|
+
"auth.acl.features.channel_smtp.view": "SMTP 채널 구성 보기",
|
|
110
112
|
"auth.acl.features.checkout.create": "Create checkout links and templates",
|
|
111
113
|
"auth.acl.features.checkout.delete": "Delete checkout links and templates",
|
|
112
114
|
"auth.acl.features.checkout.edit": "Edit checkout links and templates",
|
|
@@ -442,6 +444,7 @@
|
|
|
442
444
|
"auth.acl.modules.channel_imap": "IMAP + SMTP Email Channel",
|
|
443
445
|
"auth.acl.modules.channel_resend": "Resend Email Channel",
|
|
444
446
|
"auth.acl.modules.channel_ses": "Amazon SES Email Channel",
|
|
447
|
+
"auth.acl.modules.channel_smtp": "SMTP 이메일 채널",
|
|
445
448
|
"auth.acl.modules.checkout": "Checkout",
|
|
446
449
|
"auth.acl.modules.communication_channels": "Communication Channels",
|
|
447
450
|
"auth.acl.modules.configs": "Configuration",
|