@open-mercato/enterprise 0.6.4-develop.4371.1.8f3030407e → 0.6.4
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/modules/record_locks/widgets/injection/record-locking/widget.client.js +1 -1
- package/dist/modules/record_locks/widgets/injection/record-locking/widget.client.js.map +2 -2
- package/dist/modules/security/api/enforcement/[id]/route.js +35 -1
- package/dist/modules/security/api/enforcement/[id]/route.js.map +2 -2
- package/dist/modules/security/api/enforcement/_shared.js +63 -1
- package/dist/modules/security/api/enforcement/_shared.js.map +3 -3
- package/dist/modules/security/api/enforcement/compliance/route.js +12 -3
- package/dist/modules/security/api/enforcement/compliance/route.js.map +2 -2
- package/dist/modules/security/api/enforcement/route.js +25 -2
- package/dist/modules/security/api/enforcement/route.js.map +2 -2
- package/dist/modules/security/api/mfa/prepare/route.js +1 -1
- package/dist/modules/security/api/mfa/prepare/route.js.map +2 -2
- package/dist/modules/security/api/mfa/recovery/route.js +1 -1
- package/dist/modules/security/api/mfa/recovery/route.js.map +2 -2
- package/dist/modules/security/api/mfa/verify/route.js +1 -1
- package/dist/modules/security/api/mfa/verify/route.js.map +2 -2
- package/dist/modules/security/api/users/[id]/mfa/reset/route.js +6 -1
- package/dist/modules/security/api/users/[id]/mfa/reset/route.js.map +2 -2
- package/dist/modules/security/api/users/[id]/mfa/status/route.js +13 -2
- package/dist/modules/security/api/users/[id]/mfa/status/route.js.map +2 -2
- package/dist/modules/security/api/users/_shared.js +56 -1
- package/dist/modules/security/api/users/_shared.js.map +2 -2
- package/dist/modules/security/api/users/mfa/compliance/route.js +17 -7
- package/dist/modules/security/api/users/mfa/compliance/route.js.map +2 -2
- package/dist/modules/security/commands/createEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/createEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/commands/deleteEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/deleteEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/commands/resetUserMfa.js +6 -1
- package/dist/modules/security/commands/resetUserMfa.js.map +2 -2
- package/dist/modules/security/commands/updateEnforcementPolicy.js +6 -1
- package/dist/modules/security/commands/updateEnforcementPolicy.js.map +2 -2
- package/dist/modules/security/services/MfaAdminService.js +22 -5
- package/dist/modules/security/services/MfaAdminService.js.map +2 -2
- package/dist/modules/security/services/MfaEnforcementService.js +28 -6
- package/dist/modules/security/services/MfaEnforcementService.js.map +2 -2
- package/dist/modules/security/services/MfaVerificationService.js +30 -10
- package/dist/modules/security/services/MfaVerificationService.js.map +2 -2
- package/dist/modules/security/services/SudoChallengeService.js +14 -3
- package/dist/modules/security/services/SudoChallengeService.js.map +2 -2
- package/dist/modules/sso/api/callback/oidc/route.js +2 -2
- package/dist/modules/sso/api/callback/oidc/route.js.map +2 -2
- package/dist/modules/sso/i18n/de.json +2 -0
- package/dist/modules/sso/i18n/en.json +2 -0
- package/dist/modules/sso/i18n/es.json +2 -0
- package/dist/modules/sso/i18n/pl.json +2 -0
- package/dist/modules/sso/lib/errors.js +21 -0
- package/dist/modules/sso/lib/errors.js.map +7 -0
- package/dist/modules/sso/services/accountLinkingService.js +2 -1
- package/dist/modules/sso/services/accountLinkingService.js.map +2 -2
- package/package.json +7 -8
- package/src/modules/record_locks/widgets/injection/record-locking/widget.client.tsx +1 -1
- package/src/modules/security/api/enforcement/[id]/route.ts +50 -1
- package/src/modules/security/api/enforcement/_shared.ts +83 -2
- package/src/modules/security/api/enforcement/compliance/route.ts +10 -1
- package/src/modules/security/api/enforcement/route.ts +30 -2
- package/src/modules/security/api/mfa/prepare/route.ts +1 -1
- package/src/modules/security/api/mfa/recovery/route.ts +1 -1
- package/src/modules/security/api/mfa/verify/route.ts +1 -1
- package/src/modules/security/api/users/[id]/mfa/reset/route.ts +6 -1
- package/src/modules/security/api/users/[id]/mfa/status/route.ts +13 -2
- package/src/modules/security/api/users/_shared.ts +69 -1
- package/src/modules/security/api/users/mfa/compliance/route.ts +16 -7
- package/src/modules/security/commands/createEnforcementPolicy.ts +6 -1
- package/src/modules/security/commands/deleteEnforcementPolicy.ts +6 -1
- package/src/modules/security/commands/resetUserMfa.ts +6 -1
- package/src/modules/security/commands/updateEnforcementPolicy.ts +6 -1
- package/src/modules/security/services/MfaAdminService.ts +29 -6
- package/src/modules/security/services/MfaEnforcementService.ts +42 -2
- package/src/modules/security/services/MfaVerificationService.ts +33 -10
- package/src/modules/security/services/SudoChallengeService.ts +16 -11
- package/src/modules/sso/api/callback/oidc/route.ts +2 -2
- package/src/modules/sso/i18n/de.json +2 -0
- package/src/modules/sso/i18n/en.json +2 -0
- package/src/modules/sso/i18n/es.json +2 -0
- package/src/modules/sso/i18n/pl.json +2 -0
- package/src/modules/sso/lib/errors.ts +35 -0
- package/src/modules/sso/services/accountLinkingService.ts +2 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
var _a, _b;
|
|
2
|
+
const EMAIL_NOT_VERIFIED_ERROR_MARKER = /* @__PURE__ */ Symbol.for("@open-mercato/sso/EmailNotVerifiedError");
|
|
3
|
+
class EmailNotVerifiedError extends (_b = Error, _a = EMAIL_NOT_VERIFIED_ERROR_MARKER, _b) {
|
|
4
|
+
constructor(message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this[_a] = true;
|
|
7
|
+
this.name = "EmailNotVerifiedError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isEmailNotVerifiedError(err) {
|
|
11
|
+
return !!err && typeof err === "object" && err[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true;
|
|
12
|
+
}
|
|
13
|
+
function resolveSsoCallbackErrorCode(err) {
|
|
14
|
+
return isEmailNotVerifiedError(err) ? "sso_email_not_verified" : "sso_failed";
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
EmailNotVerifiedError,
|
|
18
|
+
isEmailNotVerifiedError,
|
|
19
|
+
resolveSsoCallbackErrorCode
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/sso/lib/errors.ts"],
|
|
4
|
+
"sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle\n// boundaries: the OIDC callback route and the account-linking service can be\n// bundled into separate chunks where `instanceof` silently returns false\n// (same rationale as isCrudHttpError in @open-mercato/shared).\nconst EMAIL_NOT_VERIFIED_ERROR_MARKER = Symbol.for('@open-mercato/sso/EmailNotVerifiedError')\n\nexport class EmailNotVerifiedError extends Error {\n readonly [EMAIL_NOT_VERIFIED_ERROR_MARKER] = true\n\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'EmailNotVerifiedError'\n }\n}\n\n/**\n * Type-safe check that works across module/bundle boundaries. Prefer this over\n * `instanceof EmailNotVerifiedError` because the SSO callback route may be\n * bundled separately from the service that throws the error.\n */\nexport function isEmailNotVerifiedError(err: unknown): err is EmailNotVerifiedError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[EMAIL_NOT_VERIFIED_ERROR_MARKER] === true\n}\n\nexport type SsoCallbackErrorCode = 'sso_email_not_verified' | 'sso_failed'\n\n/**\n * Maps an error thrown during the OIDC callback to the login-page UX error code.\n * Keyed off the error type rather than a substring of the human-readable message,\n * which previously drifted out of sync and left `sso_email_not_verified`\n * unreachable (#2741).\n */\nexport function resolveSsoCallbackErrorCode(err: unknown): SsoCallbackErrorCode {\n return isEmailNotVerifiedError(err) ? 'sso_email_not_verified' : 'sso_failed'\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAIA,MAAM,kCAAkC,uBAAO,IAAI,yCAAyC;AAErF,MAAM,+BAA8B,YAC/B,sCAD+B,IAAM;AAAA,EAG/C,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AAHxB,SAAU,MAAmC;AAI3C,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,wBAAwB,KAA4C;AAClF,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,+BAA+B,MAAM;AACnH;AAUO,SAAS,4BAA4B,KAAoC;AAC9E,SAAO,wBAAwB,GAAG,IAAI,2BAA2B;AACnE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -3,6 +3,7 @@ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find"
|
|
|
3
3
|
import { computeEmailHash } from "@open-mercato/core/modules/auth/lib/emailHash";
|
|
4
4
|
import { SsoIdentity, SsoRoleGrant, ScimToken } from "../data/entities.js";
|
|
5
5
|
import { emitSsoEvent } from "../events.js";
|
|
6
|
+
import { EmailNotVerifiedError } from "../lib/errors.js";
|
|
6
7
|
class AccountLinkingService {
|
|
7
8
|
constructor(em) {
|
|
8
9
|
this.em = em;
|
|
@@ -14,7 +15,7 @@ class AccountLinkingService {
|
|
|
14
15
|
return existing;
|
|
15
16
|
}
|
|
16
17
|
if (idpPayload.emailVerified === false) {
|
|
17
|
-
throw new
|
|
18
|
+
throw new EmailNotVerifiedError("IdP explicitly reported email as unverified \u2014 cannot link or provision account");
|
|
18
19
|
}
|
|
19
20
|
const emailDomain = idpPayload.email.split("@")[1]?.toLowerCase();
|
|
20
21
|
if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sso/services/accountLinkingService.ts"],
|
|
4
|
-
"sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new Error('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,MAAM,UAAU,YAAY;AACrC,SAAS,6BAA6B;AACtC,SAAS,wBAAwB;AACjC,SAAoB,aAAa,cAAc,iBAAiB;AAChE,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'\nimport { User, UserRole, Role } from '@open-mercato/core/modules/auth/data/entities'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { SsoConfig, SsoIdentity, SsoRoleGrant, ScimToken } from '../data/entities'\nimport { emitSsoEvent } from '../events'\nimport { EmailNotVerifiedError } from '../lib/errors'\nimport type { SsoIdentityPayload } from '../lib/types'\n\nexport class AccountLinkingService {\n constructor(private em: EntityManager) {}\n\n async resolveUser(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n const existing = await this.findExistingLink(config.id, idpPayload.subject, tenantId, config.organizationId)\n if (existing) {\n await this.assignRolesFromSso(this.em, existing.user, config, tenantId, idpPayload.groups)\n return existing\n }\n\n if (idpPayload.emailVerified === false) {\n throw new EmailNotVerifiedError('IdP explicitly reported email as unverified \u2014 cannot link or provision account')\n }\n\n const emailDomain = idpPayload.email.split('@')[1]?.toLowerCase()\n if (!emailDomain || !config.allowedDomains.some((d) => d.toLowerCase() === emailDomain)) {\n throw new Error('Email domain is not in the allowed domains for this SSO configuration')\n }\n\n const emailLinked = config.autoLinkByEmail\n ? await this.linkByEmail(config, idpPayload, tenantId)\n : null\n if (emailLinked) {\n await this.assignRolesFromSso(this.em, emailLinked.user, config, tenantId, idpPayload.groups)\n return emailLinked\n }\n\n if (config.jitEnabled) {\n const scimActive = await this.em.count(ScimToken, { ssoConfigId: config.id, isActive: true }) > 0\n if (scimActive) {\n throw new Error('JIT provisioning is disabled because SCIM directory sync is active')\n }\n return this.jitProvision(config, idpPayload, tenantId)\n }\n\n throw new Error('No matching user found and JIT provisioning is disabled')\n }\n\n private async findExistingLink(\n ssoConfigId: string,\n idpSubject: string,\n tenantId: string,\n organizationId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const identity = await findOneWithDecryption(\n this.em,\n SsoIdentity,\n { ssoConfigId, idpSubject, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!identity) return null\n\n const user = await findOneWithDecryption(\n this.em,\n User,\n { id: identity.userId, deletedAt: null },\n {},\n { tenantId, organizationId },\n )\n if (!user) {\n identity.deletedAt = new Date()\n await this.em.flush()\n return null\n }\n\n identity.lastLoginAt = new Date()\n await this.em.flush()\n\n return { user, identity }\n }\n\n private async linkByEmail(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity } | null> {\n const emailHash = computeEmailHash(idpPayload.email)\n const user = await findOneWithDecryption(\n this.em,\n User,\n {\n organizationId: config.organizationId,\n deletedAt: null,\n $or: [\n { email: idpPayload.email },\n { emailHash },\n ],\n } as FilterQuery<User>,\n {},\n { tenantId, organizationId: config.organizationId },\n )\n if (!user) return null\n\n const now = new Date()\n const identity = this.em.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'manual',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await this.em.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.linked', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n }\n\n private async jitProvision(\n config: SsoConfig,\n idpPayload: SsoIdentityPayload,\n tenantId: string,\n ): Promise<{ user: User; identity: SsoIdentity }> {\n return this.em.transactional(async (txEm) => {\n const user = txEm.create(User, {\n tenantId,\n organizationId: config.organizationId,\n email: idpPayload.email,\n emailHash: computeEmailHash(idpPayload.email),\n name: idpPayload.name ?? null,\n passwordHash: null,\n isConfirmed: true,\n createdAt: new Date(),\n })\n await txEm.persist(user).flush()\n\n await this.assignRolesFromSso(txEm, user, config, tenantId, idpPayload.groups)\n\n const now = new Date()\n const identity = txEm.create(SsoIdentity, {\n tenantId,\n organizationId: config.organizationId,\n ssoConfigId: config.id,\n userId: user.id,\n idpSubject: idpPayload.subject,\n idpEmail: idpPayload.email,\n idpName: idpPayload.name ?? null,\n idpGroups: idpPayload.groups ?? [],\n provisioningMethod: 'jit',\n firstLoginAt: now,\n lastLoginAt: now,\n createdAt: now,\n updatedAt: now,\n } as RequiredEntityData<SsoIdentity>)\n await txEm.persist(identity).flush()\n\n void emitSsoEvent('sso.identity.created', {\n id: identity.id,\n tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return { user, identity }\n })\n }\n\n private async assignRolesFromSso(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const hasMappings = config.appRoleMappings && Object.keys(config.appRoleMappings).length > 0\n if (!hasMappings) return\n\n await this.syncMappedRoles(em, user, config, tenantId, idpGroups)\n\n const hasAnySsoRole = await em.findOne(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n if (!hasAnySsoRole) {\n throw new Error('No roles could be resolved from IdP groups \u2014 login denied. Configure role mappings or ensure the IdP sends matching group claims.')\n }\n }\n\n /**\n * Sync/replace SSO-sourced roles: on each login, SSO-managed roles are replaced\n * with what the IdP sends, while manually-assigned roles are preserved.\n */\n private async syncMappedRoles(\n em: EntityManager,\n user: User,\n config: SsoConfig,\n tenantId: string,\n idpGroups?: string[],\n ): Promise<void> {\n const resolvedTenantId = tenantId || user.tenantId || ''\n if (!resolvedTenantId) return\n\n const allRoles = await em.find(Role, { tenantId: resolvedTenantId, deletedAt: null } as FilterQuery<Role>)\n const roleByNormalizedName = new Map<string, Role>()\n for (const role of allRoles) {\n const normalized = normalizeToken(role.name)\n if (normalized) roleByNormalizedName.set(normalized, role)\n }\n\n // Resolve desired role IDs from IdP groups using merged mappings\n const desiredRoleNames = resolveRoleNamesFromIdpGroups(idpGroups, config.appRoleMappings)\n const desiredRoleIds = new Set<string>()\n for (const roleName of desiredRoleNames) {\n const role = roleByNormalizedName.get(roleName)\n if (role) desiredRoleIds.add(role.id)\n }\n\n // Query current SSO grants for this user+config\n const existingGrants = await em.find(SsoRoleGrant, {\n userId: user.id,\n ssoConfigId: config.id,\n })\n const existingGrantedRoleIds = new Set(existingGrants.map((g) => g.roleId))\n\n // Compute diff\n const toAdd = [...desiredRoleIds].filter((id) => !existingGrantedRoleIds.has(id))\n const toRemove = existingGrants.filter((g) => !desiredRoleIds.has(g.roleId))\n\n // Add new roles\n for (const roleId of toAdd) {\n const role = allRoles.find((r) => r.id === roleId)\n if (!role) continue\n await this.ensureUserRole(em, user, role)\n const grant = em.create(SsoRoleGrant, {\n tenantId: resolvedTenantId,\n organizationId: config.organizationId,\n userId: user.id,\n roleId,\n ssoConfigId: config.id,\n } as RequiredEntityData<SsoRoleGrant>)\n em.persist(grant)\n }\n\n // Remove stale SSO-sourced roles\n for (const grant of toRemove) {\n const userRole = await em.findOne(UserRole, {\n user: user.id,\n role: grant.roleId,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (userRole) {\n em.remove(userRole)\n }\n em.remove(grant)\n }\n\n // Clean up orphaned soft-deleted UserRole rows (ghost rows from previous soft-delete logic)\n const allUserRoles = await em.find(UserRole, { user: user.id } as FilterQuery<UserRole>)\n for (const ur of allUserRoles) {\n if (ur.deletedAt) {\n em.remove(ur)\n }\n }\n\n if (toAdd.length > 0 || toRemove.length > 0 || allUserRoles.some((ur) => ur.deletedAt)) {\n await em.flush()\n }\n }\n\n private async ensureUserRole(em: EntityManager, user: User, role: Role): Promise<void> {\n const existingLink = await em.findOne(UserRole, {\n user: user.id,\n role: role.id,\n deletedAt: null,\n } as FilterQuery<UserRole>)\n if (existingLink) return\n\n const userRole = em.create(UserRole, { user, role, createdAt: new Date() })\n await em.persist(userRole).flush()\n }\n}\n\nfunction resolveRoleNamesFromIdpGroups(\n idpGroups?: string[],\n configMappings?: Record<string, string>,\n): string[] {\n if (!Array.isArray(idpGroups) || idpGroups.length === 0) return []\n\n const normalizedGroups = idpGroups\n .map((group) => normalizeToken(group))\n .filter((group): group is string => group !== null)\n if (normalizedGroups.length === 0) return []\n\n const mergedMappings = loadMergedMappings(configMappings)\n const roleNames = new Set<string>()\n\n for (const group of normalizedGroups) {\n const mapped = mergedMappings.get(group)\n if (mapped?.length) {\n for (const role of mapped) roleNames.add(role)\n continue\n }\n\n roleNames.add(group)\n const segmented = group.split(/[\\\\/:]/).map((part) => normalizeToken(part)).filter((part): part is string => part !== null)\n for (const candidate of segmented) {\n roleNames.add(candidate)\n }\n }\n\n return Array.from(roleNames)\n}\n\nfunction loadMergedMappings(configMappings?: Record<string, string>): Map<string, string[]> {\n const envMappings = loadGroupRoleMappingsFromEnv()\n\n // Per-config mappings take precedence over env var\n if (configMappings && Object.keys(configMappings).length > 0) {\n for (const [group, roleName] of Object.entries(configMappings)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const normalizedRole = normalizeToken(roleName)\n if (!normalizedRole) continue\n envMappings.set(normalizedGroup, [normalizedRole])\n }\n }\n\n return envMappings\n}\n\nfunction loadGroupRoleMappingsFromEnv(): Map<string, string[]> {\n const raw = process.env.SSO_GROUP_ROLE_MAP\n if (!raw) return new Map()\n\n try {\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const out = new Map<string, string[]>()\n for (const [group, roleValue] of Object.entries(parsed)) {\n const normalizedGroup = normalizeToken(group)\n if (!normalizedGroup) continue\n const roles = normalizeRoleList(roleValue)\n if (roles.length > 0) out.set(normalizedGroup, roles)\n }\n return out\n } catch {\n return new Map()\n }\n}\n\nfunction normalizeRoleList(value: unknown): string[] {\n if (typeof value === 'string') {\n const token = normalizeToken(value)\n return token ? [token] : []\n }\n\n if (Array.isArray(value)) {\n const out = new Set<string>()\n for (const entry of value) {\n const token = normalizeToken(entry)\n if (token) out.add(token)\n }\n return Array.from(out)\n }\n\n return []\n}\n\nfunction normalizeToken(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n return normalized.length > 0 ? normalized : null\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/enterprise",
|
|
3
|
-
"version": "0.6.4
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@open-mercato/core": "0.6.4
|
|
68
|
-
"@open-mercato/ui": "0.6.4
|
|
67
|
+
"@open-mercato/core": "0.6.4",
|
|
68
|
+
"@open-mercato/ui": "0.6.4",
|
|
69
69
|
"@simplewebauthn/browser": "^13.3.0",
|
|
70
70
|
"@simplewebauthn/server": "^13.3.1",
|
|
71
71
|
"@simplewebauthn/types": "^12.0.0",
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
"qrcode": "^1.5.4"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@open-mercato/shared": "0.6.4
|
|
78
|
+
"@open-mercato/shared": "0.6.4",
|
|
79
79
|
"react": "^19.0.0",
|
|
80
80
|
"react-dom": "^19.0.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@open-mercato/shared": "0.6.4
|
|
83
|
+
"@open-mercato/shared": "0.6.4",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
|
-
"@types/react": "^19.2.
|
|
85
|
+
"@types/react": "^19.2.17",
|
|
86
86
|
"@types/react-dom": "^19.2.3",
|
|
87
87
|
"jest": "^30.4.2",
|
|
88
88
|
"react": "19.2.7",
|
|
@@ -96,6 +96,5 @@
|
|
|
96
96
|
"type": "git",
|
|
97
97
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
98
98
|
"directory": "packages/enterprise"
|
|
99
|
-
}
|
|
100
|
-
"stableVersion": "0.6.3"
|
|
99
|
+
}
|
|
101
100
|
}
|
|
@@ -2,14 +2,48 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
4
4
|
import { updateEnforcementPolicySchema } from '../../../data/validators'
|
|
5
|
+
import { EnforcementScope } from '../../../data/entities'
|
|
6
|
+
import type { MfaEnforcementPolicy } from '../../../data/entities'
|
|
7
|
+
import type { UpdateEnforcementPolicyInput } from '../../../data/validators'
|
|
5
8
|
import { buildSecurityOpenApi, securityErrorSchema } from '../../openapi'
|
|
6
9
|
import { securityApiError } from '../../i18n'
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assertActorOwnsEnforcementScope,
|
|
12
|
+
mapEnforcementError,
|
|
13
|
+
resolveEnforcementContext,
|
|
14
|
+
type EnforcementRequestContext,
|
|
15
|
+
} from '../_shared'
|
|
8
16
|
|
|
9
17
|
const paramsSchema = z.object({
|
|
10
18
|
id: z.string().uuid(),
|
|
11
19
|
})
|
|
12
20
|
|
|
21
|
+
function scopeIdFromPolicy(policy: {
|
|
22
|
+
scope: EnforcementScope
|
|
23
|
+
tenantId?: string | null
|
|
24
|
+
organizationId?: string | null
|
|
25
|
+
}): string | null {
|
|
26
|
+
if (policy.scope === EnforcementScope.TENANT) {
|
|
27
|
+
return policy.tenantId ?? null
|
|
28
|
+
}
|
|
29
|
+
if (policy.scope === EnforcementScope.ORGANISATION) {
|
|
30
|
+
if (!policy.tenantId || !policy.organizationId) return null
|
|
31
|
+
return `${policy.tenantId}:${policy.organizationId}`
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function assertActorOwnsRequestedPolicyScope(
|
|
37
|
+
context: EnforcementRequestContext,
|
|
38
|
+
current: MfaEnforcementPolicy,
|
|
39
|
+
data: UpdateEnforcementPolicyInput,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const scope = data.scope ?? current.scope
|
|
42
|
+
const tenantId = data.tenantId ?? current.tenantId ?? null
|
|
43
|
+
const organizationId = data.organizationId ?? current.organizationId ?? null
|
|
44
|
+
await assertActorOwnsEnforcementScope(context, scope, scopeIdFromPolicy({ scope, tenantId, organizationId }))
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
const okResponseSchema = z.object({
|
|
14
48
|
ok: z.literal(true),
|
|
15
49
|
})
|
|
@@ -41,6 +75,13 @@ export async function PUT(req: Request, context: { params: Promise<{ id: string
|
|
|
41
75
|
}
|
|
42
76
|
|
|
43
77
|
try {
|
|
78
|
+
const policy = await requestContext.enforcementService.getPolicyById(params.data.id)
|
|
79
|
+
if (!policy) {
|
|
80
|
+
return securityApiError(404, 'Enforcement policy not found')
|
|
81
|
+
}
|
|
82
|
+
await assertActorOwnsEnforcementScope(requestContext, policy.scope, scopeIdFromPolicy(policy))
|
|
83
|
+
await assertActorOwnsRequestedPolicyScope(requestContext, policy, parsedBody.data)
|
|
84
|
+
|
|
44
85
|
const commandBus = requestContext.container.resolve<CommandBus>('commandBus')
|
|
45
86
|
const { result } = await commandBus.execute('security.enforcement.update', {
|
|
46
87
|
input: {
|
|
@@ -65,6 +106,12 @@ export async function DELETE(req: Request, context: { params: Promise<{ id: stri
|
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
try {
|
|
109
|
+
const policy = await requestContext.enforcementService.getPolicyById(params.data.id)
|
|
110
|
+
if (!policy) {
|
|
111
|
+
return securityApiError(404, 'Enforcement policy not found')
|
|
112
|
+
}
|
|
113
|
+
await assertActorOwnsEnforcementScope(requestContext, policy.scope, scopeIdFromPolicy(policy))
|
|
114
|
+
|
|
68
115
|
const commandBus = requestContext.container.resolve<CommandBus>('commandBus')
|
|
69
116
|
const { result } = await commandBus.execute('security.enforcement.delete', {
|
|
70
117
|
input: { id: params.data.id },
|
|
@@ -92,6 +139,7 @@ export const openApi = buildSecurityOpenApi({
|
|
|
92
139
|
errors: [
|
|
93
140
|
{ status: 400, description: 'Invalid input', schema: securityErrorSchema },
|
|
94
141
|
{ status: 401, description: 'Unauthorized', schema: securityErrorSchema },
|
|
142
|
+
{ status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
|
|
95
143
|
{ status: 404, description: 'Policy not found', schema: securityErrorSchema },
|
|
96
144
|
{ status: 409, description: 'Conflict', schema: securityErrorSchema },
|
|
97
145
|
],
|
|
@@ -105,6 +153,7 @@ export const openApi = buildSecurityOpenApi({
|
|
|
105
153
|
errors: [
|
|
106
154
|
{ status: 400, description: 'Invalid policy id', schema: securityErrorSchema },
|
|
107
155
|
{ status: 401, description: 'Unauthorized', schema: securityErrorSchema },
|
|
156
|
+
{ status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
|
|
108
157
|
{ status: 404, description: 'Policy not found', schema: securityErrorSchema },
|
|
109
158
|
],
|
|
110
159
|
},
|
|
@@ -2,16 +2,23 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
4
4
|
import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
|
|
5
|
-
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
|
|
6
6
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
7
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
-
import
|
|
8
|
+
import { enforceTenantSelection, resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
9
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
10
|
+
import { EnforcementScope, type MfaEnforcementPolicy } from '../../data/entities'
|
|
9
11
|
import type { MfaEnforcementServiceError, MfaEnforcementService } from '../../services/MfaEnforcementService'
|
|
10
12
|
import { localizeSecurityApiBody, securityApiError } from '../i18n'
|
|
11
13
|
|
|
12
14
|
type RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>
|
|
13
15
|
type Auth = NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>
|
|
14
16
|
|
|
17
|
+
export type EnforcementActorContext = {
|
|
18
|
+
tenantId: string | null
|
|
19
|
+
isSuperAdmin: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export type EnforcementRequestContext = {
|
|
16
23
|
auth: Auth
|
|
17
24
|
container: RequestContainer
|
|
@@ -41,6 +48,80 @@ export async function resolveEnforcementContext(req: Request): Promise<Enforceme
|
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
function normalizeNullableString(value: unknown): string | null {
|
|
52
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeOrganizationList(values: unknown): string[] | null {
|
|
56
|
+
if (values === null || values === undefined) return null
|
|
57
|
+
if (!Array.isArray(values)) return null
|
|
58
|
+
const result: string[] = []
|
|
59
|
+
for (const value of values) {
|
|
60
|
+
if (typeof value !== 'string') continue
|
|
61
|
+
const trimmed = value.trim()
|
|
62
|
+
if (trimmed) result.push(trimmed)
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveActorContext(ctx: EnforcementRequestContext): Promise<EnforcementActorContext> {
|
|
68
|
+
const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
|
|
69
|
+
return {
|
|
70
|
+
tenantId: normalizeNullableString(ctx.auth.tenantId),
|
|
71
|
+
isSuperAdmin,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function assertActorOwnsOrganization(
|
|
76
|
+
ctx: EnforcementRequestContext,
|
|
77
|
+
organizationId: string,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const rbacService = ctx.container.resolve<RbacService>('rbacService')
|
|
80
|
+
const acl = await rbacService.loadAcl(ctx.auth.sub, {
|
|
81
|
+
tenantId: normalizeNullableString(ctx.auth.tenantId),
|
|
82
|
+
organizationId: normalizeNullableString(ctx.auth.orgId),
|
|
83
|
+
})
|
|
84
|
+
const organizations = normalizeOrganizationList(acl?.organizations)
|
|
85
|
+
if (organizations === null || organizations.includes('__all__')) return
|
|
86
|
+
if (!organizations.includes(organizationId)) {
|
|
87
|
+
throw forbidden('Not authorized to target this organization.')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function assertActorOwnsEnforcementScope(
|
|
92
|
+
ctx: EnforcementRequestContext,
|
|
93
|
+
scope: EnforcementScope,
|
|
94
|
+
scopeId: string | null | undefined,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
if (scope === EnforcementScope.PLATFORM) {
|
|
97
|
+
const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
|
|
98
|
+
if (!isSuperAdmin) {
|
|
99
|
+
throw forbidden('Platform scope requires platform administrator privileges.')
|
|
100
|
+
}
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (scope === EnforcementScope.TENANT) {
|
|
105
|
+
await enforceTenantSelection({ auth: ctx.auth, container: ctx.container }, scopeId)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const normalizedScopeId = normalizeNullableString(scopeId)
|
|
110
|
+
if (!normalizedScopeId) {
|
|
111
|
+
throw new CrudHttpError(400, { error: "organisation scopeId must use '<tenantId>:<organizationId>' format" })
|
|
112
|
+
}
|
|
113
|
+
const [tenantId, organizationId] = normalizedScopeId.split(':')
|
|
114
|
+
if (!tenantId || !organizationId) {
|
|
115
|
+
throw new CrudHttpError(400, { error: "organisation scopeId must use '<tenantId>:<organizationId>' format" })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await enforceTenantSelection({ auth: ctx.auth, container: ctx.container }, tenantId)
|
|
119
|
+
|
|
120
|
+
const isSuperAdmin = await resolveIsSuperAdmin({ auth: ctx.auth, container: ctx.container })
|
|
121
|
+
if (isSuperAdmin) return
|
|
122
|
+
await assertActorOwnsOrganization(ctx, organizationId)
|
|
123
|
+
}
|
|
124
|
+
|
|
44
125
|
export async function mapEnforcementError(error: unknown): Promise<NextResponse> {
|
|
45
126
|
if (error instanceof CrudHttpError) {
|
|
46
127
|
return NextResponse.json(await localizeSecurityApiBody(error.body), { status: error.status })
|
|
@@ -3,7 +3,12 @@ import { z } from 'zod'
|
|
|
3
3
|
import { EnforcementScope } from '../../../data/entities'
|
|
4
4
|
import { buildSecurityOpenApi, securityErrorSchema } from '../../openapi'
|
|
5
5
|
import { securityApiError } from '../../i18n'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
assertActorOwnsEnforcementScope,
|
|
8
|
+
mapEnforcementError,
|
|
9
|
+
resolveActorContext,
|
|
10
|
+
resolveEnforcementContext,
|
|
11
|
+
} from '../_shared'
|
|
7
12
|
|
|
8
13
|
const complianceQuerySchema = z.object({
|
|
9
14
|
scope: z.nativeEnum(EnforcementScope).default(EnforcementScope.PLATFORM),
|
|
@@ -37,9 +42,12 @@ export async function GET(req: Request) {
|
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
try {
|
|
45
|
+
await assertActorOwnsEnforcementScope(context, parsedQuery.data.scope, parsedQuery.data.scopeId)
|
|
46
|
+
const actor = await resolveActorContext(context)
|
|
40
47
|
const report = await context.enforcementService.getComplianceReport(
|
|
41
48
|
parsedQuery.data.scope,
|
|
42
49
|
parsedQuery.data.scopeId,
|
|
50
|
+
actor,
|
|
43
51
|
)
|
|
44
52
|
return NextResponse.json({
|
|
45
53
|
scope: parsedQuery.data.scope,
|
|
@@ -63,6 +71,7 @@ export const openApi = buildSecurityOpenApi({
|
|
|
63
71
|
errors: [
|
|
64
72
|
{ status: 400, description: 'Invalid query parameters', schema: securityErrorSchema },
|
|
65
73
|
{ status: 401, description: 'Unauthorized', schema: securityErrorSchema },
|
|
74
|
+
{ status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
|
|
66
75
|
],
|
|
67
76
|
},
|
|
68
77
|
},
|
|
@@ -5,7 +5,13 @@ import { enforcementPolicySchema } from '../../data/validators'
|
|
|
5
5
|
import { EnforcementScope } from '../../data/entities'
|
|
6
6
|
import { buildSecurityOpenApi, securityErrorSchema } from '../openapi'
|
|
7
7
|
import { securityApiError } from '../i18n'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertActorOwnsEnforcementScope,
|
|
10
|
+
attachPolicyScopeNames,
|
|
11
|
+
mapEnforcementError,
|
|
12
|
+
resolveActorContext,
|
|
13
|
+
resolveEnforcementContext,
|
|
14
|
+
} from './_shared'
|
|
9
15
|
|
|
10
16
|
const enforcementPolicyResponseSchema = z.object({
|
|
11
17
|
id: z.string().uuid(),
|
|
@@ -52,7 +58,8 @@ export async function GET(req: Request) {
|
|
|
52
58
|
return securityApiError(400, 'Invalid query parameters', { issues: parsedQuery.error.issues })
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
const
|
|
61
|
+
const actor = await resolveActorContext(context)
|
|
62
|
+
const policies = await context.enforcementService.listPolicies(parsedQuery.data, actor)
|
|
56
63
|
return NextResponse.json({
|
|
57
64
|
items: await attachPolicyScopeNames(context.container, policies),
|
|
58
65
|
})
|
|
@@ -78,6 +85,11 @@ export async function POST(req: Request) {
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
try {
|
|
88
|
+
await assertActorOwnsEnforcementScope(
|
|
89
|
+
context,
|
|
90
|
+
parsedBody.data.scope,
|
|
91
|
+
scopeIdFromPolicyInput(parsedBody.data),
|
|
92
|
+
)
|
|
81
93
|
const commandBus = context.container.resolve<CommandBus>('commandBus')
|
|
82
94
|
const { result } = await commandBus.execute('security.enforcement.create', {
|
|
83
95
|
input: parsedBody.data,
|
|
@@ -89,6 +101,21 @@ export async function POST(req: Request) {
|
|
|
89
101
|
}
|
|
90
102
|
}
|
|
91
103
|
|
|
104
|
+
function scopeIdFromPolicyInput(data: {
|
|
105
|
+
scope: EnforcementScope
|
|
106
|
+
tenantId?: string | null
|
|
107
|
+
organizationId?: string | null
|
|
108
|
+
}): string | null {
|
|
109
|
+
if (data.scope === EnforcementScope.TENANT) {
|
|
110
|
+
return data.tenantId ?? null
|
|
111
|
+
}
|
|
112
|
+
if (data.scope === EnforcementScope.ORGANISATION) {
|
|
113
|
+
if (!data.tenantId || !data.organizationId) return null
|
|
114
|
+
return `${data.tenantId}:${data.organizationId}`
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
export const openApi = buildSecurityOpenApi({
|
|
93
120
|
summary: 'Enforcement policy routes',
|
|
94
121
|
methods: {
|
|
@@ -115,6 +142,7 @@ export const openApi = buildSecurityOpenApi({
|
|
|
115
142
|
errors: [
|
|
116
143
|
{ status: 400, description: 'Invalid payload', schema: securityErrorSchema },
|
|
117
144
|
{ status: 401, description: 'Unauthorized', schema: securityErrorSchema },
|
|
145
|
+
{ status: 403, description: 'Not authorized for the requested scope', schema: securityErrorSchema },
|
|
118
146
|
{ status: 409, description: 'Conflict', schema: securityErrorSchema },
|
|
119
147
|
],
|
|
120
148
|
},
|
|
@@ -15,7 +15,7 @@ const responseSchema = z.object({
|
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
export const metadata = {
|
|
18
|
-
POST: { requireAuth: true },
|
|
18
|
+
POST: { requireAuth: true, rateLimit: { points: 20, duration: 60, keyPrefix: 'security_mfa_prepare' } },
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function POST(req: Request) {
|
|
@@ -15,7 +15,7 @@ const responseSchema = z.object({
|
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
export const metadata = {
|
|
18
|
-
POST: { requireAuth: true },
|
|
18
|
+
POST: { requireAuth: true, rateLimit: { points: 10, duration: 60, keyPrefix: 'security_mfa_recovery' } },
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function POST(req: Request) {
|
|
@@ -17,7 +17,7 @@ const responseSchema = z.object({
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
export const metadata = {
|
|
20
|
-
POST: { requireAuth: true },
|
|
20
|
+
POST: { requireAuth: true, rateLimit: { points: 10, duration: 60, keyPrefix: 'security_mfa_verify' } },
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export async function POST(req: Request) {
|
|
@@ -3,7 +3,11 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
4
4
|
import { buildSecurityOpenApi, securityErrorSchema } from '../../../../openapi'
|
|
5
5
|
import { securityApiError } from '../../../../i18n'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
assertActorCanAccessSecurityUserTarget,
|
|
8
|
+
mapSecurityUsersError,
|
|
9
|
+
resolveSecurityUsersContext,
|
|
10
|
+
} from '../../../_shared'
|
|
7
11
|
import { requireSudo } from '../../../../../lib/sudo-middleware'
|
|
8
12
|
|
|
9
13
|
const paramsSchema = z.object({
|
|
@@ -44,6 +48,7 @@ export async function POST(req: Request, routeContext: { params: Promise<{ id: s
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
try {
|
|
51
|
+
await assertActorCanAccessSecurityUserTarget(context, parsedParams.data.id)
|
|
47
52
|
await requireSudo(req, 'security.admin.mfa.reset')
|
|
48
53
|
const commandBus = context.container.resolve<CommandBus>('commandBus')
|
|
49
54
|
const { result } = await commandBus.execute('security.admin.mfa.reset', {
|
|
@@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { buildSecurityOpenApi, securityErrorSchema } from '../../../../openapi'
|
|
4
4
|
import { securityApiError } from '../../../../i18n'
|
|
5
|
-
import {
|
|
5
|
+
import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
6
|
+
import {
|
|
7
|
+
assertActorCanAccessSecurityUserTarget,
|
|
8
|
+
mapSecurityUsersError,
|
|
9
|
+
resolveSecurityUsersContext,
|
|
10
|
+
} from '../../../_shared'
|
|
6
11
|
|
|
7
12
|
const paramsSchema = z.object({
|
|
8
13
|
id: z.string().uuid(),
|
|
@@ -35,7 +40,12 @@ export async function GET(req: Request, routeContext: { params: Promise<{ id: st
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
try {
|
|
38
|
-
|
|
43
|
+
await assertActorCanAccessSecurityUserTarget(context, parsedParams.data.id)
|
|
44
|
+
const isSuperAdmin = await resolveIsSuperAdmin({ auth: context.auth, container: context.container })
|
|
45
|
+
const status = await context.mfaAdminService.getUserMfaStatus(parsedParams.data.id, {
|
|
46
|
+
tenantId: context.auth.tenantId ?? null,
|
|
47
|
+
isSuperAdmin,
|
|
48
|
+
})
|
|
39
49
|
return NextResponse.json({
|
|
40
50
|
...status,
|
|
41
51
|
methods: status.methods.map((method) => ({
|
|
@@ -60,6 +70,7 @@ export const openApi = buildSecurityOpenApi({
|
|
|
60
70
|
errors: [
|
|
61
71
|
{ status: 400, description: 'Invalid user id', schema: securityErrorSchema },
|
|
62
72
|
{ status: 401, description: 'Unauthorized', schema: securityErrorSchema },
|
|
73
|
+
{ status: 403, description: 'Not authorized to access this user', schema: securityErrorSchema },
|
|
63
74
|
{ status: 404, description: 'User not found', schema: securityErrorSchema },
|
|
64
75
|
],
|
|
65
76
|
},
|