@open-mercato/core 0.6.3-develop.3838.1.7f2657a47c → 0.6.3-develop.3857.1.da89d7530c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/attachments/lib/access.js +6 -3
- package/dist/modules/attachments/lib/access.js.map +2 -2
- package/dist/modules/auth/frontend/login.js +6 -1
- package/dist/modules/auth/frontend/login.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +28 -0
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/customers/ai-agents.js +1 -0
- package/dist/modules/customers/ai-agents.js.map +2 -2
- package/dist/modules/sales/commands/payments.js +85 -11
- package/dist/modules/sales/commands/payments.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/lib/access.ts +15 -3
- package/src/modules/auth/frontend/login.tsx +19 -1
- package/src/modules/auth/services/rbacService.ts +38 -0
- package/src/modules/customers/ai-agents.ts +1 -0
- package/src/modules/sales/commands/payments.ts +86 -17
|
@@ -6,9 +6,12 @@ function isSuperAdminAuth(auth) {
|
|
|
6
6
|
}
|
|
7
7
|
function isSameScope(auth, attachment) {
|
|
8
8
|
if (!auth) return false;
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
9
|
+
const attachmentTenant = attachment.tenantId ?? null;
|
|
10
|
+
const attachmentOrg = attachment.organizationId ?? null;
|
|
11
|
+
if (attachmentTenant === null && attachmentOrg === null) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
return attachmentTenant === auth.tenantId && attachmentOrg === auth.orgId;
|
|
12
15
|
}
|
|
13
16
|
function checkAttachmentAccess(auth, attachment, partition, options) {
|
|
14
17
|
const superAdmin = isSuperAdminAuth(auth);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/lib/access.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { Attachment, AttachmentPartition } from '../data/entities'\n\nexport function isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {\n if (!auth) return false\n if ((auth as any).isSuperAdmin === true) return true\n const roles = Array.isArray(auth.roles) ? auth.roles : []\n return roles.some((role) => typeof role === 'string' && role.trim().toLowerCase() === 'superadmin')\n}\n\nfunction isSameScope(auth: AuthContext | null | undefined, attachment: Attachment): boolean {\n if (!auth) return false\n const
|
|
5
|
-
"mappings": "AAGO,SAAS,iBAAiB,MAA+C;AAC9E,MAAI,CAAC,KAAM,QAAO;AAClB,MAAK,KAAa,iBAAiB,KAAM,QAAO;AAChD,QAAM,QAAQ,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,CAAC;AACxD,SAAO,MAAM,KAAK,CAAC,SAAS,OAAO,SAAS,YAAY,KAAK,KAAK,EAAE,YAAY,MAAM,YAAY;AACpG;AAEA,SAAS,YAAY,MAAsC,YAAiC;AAC1F,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,
|
|
4
|
+
"sourcesContent": ["import type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { Attachment, AttachmentPartition } from '../data/entities'\n\nexport function isSuperAdminAuth(auth: AuthContext | null | undefined): boolean {\n if (!auth) return false\n if ((auth as any).isSuperAdmin === true) return true\n const roles = Array.isArray(auth.roles) ? auth.roles : []\n return roles.some((role) => typeof role === 'string' && role.trim().toLowerCase() === 'superadmin')\n}\n\nfunction isSameScope(auth: AuthContext | null | undefined, attachment: Attachment): boolean {\n if (!auth) return false\n const attachmentTenant = attachment.tenantId ?? null\n const attachmentOrg = attachment.organizationId ?? null\n // Preserve the legacy \"global attachment\" semantics: a row with both scope\n // columns null is treated as accessible to any authenticated principal.\n // The unauthenticated branch in checkAttachmentAccess already gates this on\n // partition.isPublic.\n if (attachmentTenant === null && attachmentOrg === null) {\n return true\n }\n // Fail-closed on partial-null scope. Previously a missing tenant_id or\n // organization_id was treated as \"matches any auth value\", which allowed\n // cross-tenant / cross-org access on private partitions when an attachment\n // ended up with one scope column unset. Mirrors the fail-closed pattern\n // from #2012 (mergeIdFilter).\n return attachmentTenant === auth.tenantId && attachmentOrg === auth.orgId\n}\n\nexport function checkAttachmentAccess(\n auth: AuthContext | null | undefined,\n attachment: Attachment,\n partition: AttachmentPartition,\n options?: { requireAuthForPublic?: boolean }\n): { ok: true } | { ok: false; status: number } {\n const superAdmin = isSuperAdminAuth(auth)\n const requireAuth = !partition.isPublic || options?.requireAuthForPublic === true\n\n if (requireAuth) {\n if (!auth) {\n return { ok: false, status: 401 }\n }\n if (superAdmin || isSameScope(auth, attachment)) {\n return { ok: true }\n }\n return { ok: false, status: 403 }\n }\n\n if (!auth) {\n const isTenantScoped = !!attachment.tenantId || !!attachment.organizationId\n if (isTenantScoped) {\n return { ok: false, status: 401 }\n }\n return { ok: true }\n }\n\n if (!superAdmin && !isSameScope(auth, attachment)) {\n return { ok: false, status: 403 }\n }\n return { ok: true }\n}\n"],
|
|
5
|
+
"mappings": "AAGO,SAAS,iBAAiB,MAA+C;AAC9E,MAAI,CAAC,KAAM,QAAO;AAClB,MAAK,KAAa,iBAAiB,KAAM,QAAO;AAChD,QAAM,QAAQ,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,CAAC;AACxD,SAAO,MAAM,KAAK,CAAC,SAAS,OAAO,SAAS,YAAY,KAAK,KAAK,EAAE,YAAY,MAAM,YAAY;AACpG;AAEA,SAAS,YAAY,MAAsC,YAAiC;AAC1F,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,mBAAmB,WAAW,YAAY;AAChD,QAAM,gBAAgB,WAAW,kBAAkB;AAKnD,MAAI,qBAAqB,QAAQ,kBAAkB,MAAM;AACvD,WAAO;AAAA,EACT;AAMA,SAAO,qBAAqB,KAAK,YAAY,kBAAkB,KAAK;AACtE;AAEO,SAAS,sBACd,MACA,YACA,WACA,SAC8C;AAC9C,QAAM,aAAa,iBAAiB,IAAI;AACxC,QAAM,cAAc,CAAC,UAAU,YAAY,SAAS,yBAAyB;AAE7E,MAAI,aAAa;AACf,QAAI,CAAC,MAAM;AACT,aAAO,EAAE,IAAI,OAAO,QAAQ,IAAI;AAAA,IAClC;AACA,QAAI,cAAc,YAAY,MAAM,UAAU,GAAG;AAC/C,aAAO,EAAE,IAAI,KAAK;AAAA,IACpB;AACA,WAAO,EAAE,IAAI,OAAO,QAAQ,IAAI;AAAA,EAClC;AAEA,MAAI,CAAC,MAAM;AACT,UAAM,iBAAiB,CAAC,CAAC,WAAW,YAAY,CAAC,CAAC,WAAW;AAC7D,QAAI,gBAAgB;AAClB,aAAO,EAAE,IAAI,OAAO,QAAQ,IAAI;AAAA,IAClC;AACA,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AAEA,MAAI,CAAC,cAAc,CAAC,YAAY,MAAM,UAAU,GAAG;AACjD,WAAO,EAAE,IAAI,OAAO,QAAQ,IAAI;AAAA,EAClC;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -93,6 +93,7 @@ function LoginPage() {
|
|
|
93
93
|
const [authOverride, setAuthOverride] = useState(null);
|
|
94
94
|
const [authOverridePending, setAuthOverridePending] = useState(false);
|
|
95
95
|
const [clientReady, setClientReady] = useState(false);
|
|
96
|
+
const [activeAuthenticatedUser, setActiveAuthenticatedUser] = useState(false);
|
|
96
97
|
const [email, setEmail] = useState("");
|
|
97
98
|
const [tenantId, setTenantId] = useState(null);
|
|
98
99
|
const [tenantName, setTenantName] = useState(null);
|
|
@@ -108,6 +109,7 @@ function LoginPage() {
|
|
|
108
109
|
}, []);
|
|
109
110
|
useEffect(() => {
|
|
110
111
|
let cancelled = false;
|
|
112
|
+
const hasAclChallenge = requiredFeatures.length > 0 || requiredRoles.length > 0;
|
|
111
113
|
void (async () => {
|
|
112
114
|
try {
|
|
113
115
|
const res = await apiCall("/api/auth/feature-check", {
|
|
@@ -119,6 +121,8 @@ function LoginPage() {
|
|
|
119
121
|
if (cancelled) return;
|
|
120
122
|
const activeUserId = typeof res.result?.userId === "string" ? res.result.userId : "";
|
|
121
123
|
if (!activeUserId) return;
|
|
124
|
+
setActiveAuthenticatedUser(true);
|
|
125
|
+
if (hasAclChallenge) return;
|
|
122
126
|
const rawRedirect = searchParams.get("redirect") || "";
|
|
123
127
|
let destination = "/backend";
|
|
124
128
|
if (rawRedirect) {
|
|
@@ -137,7 +141,7 @@ function LoginPage() {
|
|
|
137
141
|
return () => {
|
|
138
142
|
cancelled = true;
|
|
139
143
|
};
|
|
140
|
-
}, [router, searchParams]);
|
|
144
|
+
}, [router, searchParams, requiredFeatures.length, requiredRoles.length]);
|
|
141
145
|
useEffect(() => {
|
|
142
146
|
const tenantParam = (searchParams.get("tenant") || "").trim();
|
|
143
147
|
if (tenantParam) {
|
|
@@ -307,6 +311,7 @@ function LoginPage() {
|
|
|
307
311
|
!!translatedFeatures.length && /* @__PURE__ */ jsx(Alert, { variant: "info", className: "text-center", children: /* @__PURE__ */ jsx(AlertDescription, { children: translate("auth.login.featureDenied", "You don't have access to this feature ({feature}). Please contact your administrator.", {
|
|
308
312
|
feature: translatedFeatures.join(", ")
|
|
309
313
|
}) }) }),
|
|
314
|
+
activeAuthenticatedUser && (translatedRoles.length || translatedFeatures.length) ? /* @__PURE__ */ jsx("div", { className: "flex justify-center", "data-testid": "login-return-dashboard", children: /* @__PURE__ */ jsx(Button, { asChild: true, type: "button", variant: "outline", size: "sm", children: /* @__PURE__ */ jsx(Link, { href: "/backend", children: translate("auth.accessDenied.dashboard", "Go to Dashboard") }) }) }) : null,
|
|
310
315
|
showTenantInvalid ? /* @__PURE__ */ jsxs("div", { className: "rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-xs text-red-700", children: [
|
|
311
316
|
/* @__PURE__ */ jsx("div", { className: "font-medium", children: translate("auth.login.errors.tenantInvalid", "Tenant not found. Clear the tenant selection and try again.") }),
|
|
312
317
|
/* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "mt-2 border-red-300 text-red-700", onClick: handleClearTenant, children: [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/auth/frontend/login.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Card, CardContent, CardHeader, CardDescription } from '@open-mercato/ui/primitives/card'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { PasswordInput } from '@open-mercato/ui/primitives/password-input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport { clearAllOperations } from '@open-mercato/ui/backend/operations/store'\nimport { notifyAuthIdentityChange } from '@open-mercato/ui/backend/AuthSessionGuard'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { X } from 'lucide-react'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'\nimport type { AuthOverride, LoginFormWidgetContext } from './login-injection'\n\nconst loginTenantKey = 'om_login_tenant'\nconst loginTenantCookieMaxAge = 60 * 60 * 24 * 14\n\nfunction readTenantCookie() {\n if (typeof document === 'undefined') return null\n const entries = document.cookie.split(';')\n for (const entry of entries) {\n const [name, ...rest] = entry.trim().split('=')\n if (name === loginTenantKey) return decodeURIComponent(rest.join('='))\n }\n return null\n}\n\nfunction setTenantCookie(value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${loginTenantKey}=${encodeURIComponent(value)}; path=/; max-age=${loginTenantCookieMaxAge}; samesite=lax`\n}\n\nfunction clearTenantCookie() {\n if (typeof document === 'undefined') return\n document.cookie = `${loginTenantKey}=; path=/; max-age=0; samesite=lax`\n}\n\nfunction extractErrorMessage(payload: unknown): string | null {\n if (!payload) return null\n if (typeof payload === 'string') return payload\n if (Array.isArray(payload)) {\n for (const entry of payload) {\n const resolved = extractErrorMessage(entry)\n if (resolved) return resolved\n }\n return null\n }\n if (typeof payload === 'object') {\n const record = payload as Record<string, unknown>\n const candidates: unknown[] = [\n record.error,\n record.message,\n record.detail,\n record.details,\n record.description,\n ]\n for (const candidate of candidates) {\n const resolved = extractErrorMessage(candidate)\n if (resolved) return resolved\n }\n }\n return null\n}\n\nfunction looksLikeJsonString(value: string): boolean {\n const trimmed = value.trim()\n return trimmed.startsWith('{') || trimmed.startsWith('[')\n}\n\ntype LoginResponseEventDetail = Record<string, unknown> | null\n\ntype LoginFormSectionProps = {\n children: ReactNode\n}\n\nfunction LoginFormSectionDefault({ children }: LoginFormSectionProps) {\n return <>{children}</>\n}\n\nfunction emitLoginResponseEvent(detail: LoginResponseEventDetail) {\n if (typeof window === 'undefined') return\n window.dispatchEvent(new CustomEvent('om:auth:login-response', { detail }))\n}\n\nexport default function LoginPage() {\n const t = useT()\n const translate = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>) =>\n translateWithFallback(t, key, fallback, params),\n [t],\n )\n const router = useRouter()\n const searchParams = useSearchParams()\n const requireRole = (searchParams.get('requireRole') || searchParams.get('role') || '').trim()\n const requireFeature = (searchParams.get('requireFeature') || '').trim()\n const requiredRoles = requireRole ? requireRole.split(',').map((value) => value.trim()).filter(Boolean) : []\n const requiredFeatures = requireFeature ? requireFeature.split(',').map((value) => value.trim()).filter(Boolean) : []\n const translatedRoles = requiredRoles.map((role) => translate(`auth.roles.${role}`, role))\n const translatedFeatures = requiredFeatures.map((feature) => translate(`features.${feature}`, feature))\n const [error, setError] = useState<string | null>(null)\n const [submitting, setSubmitting] = useState(false)\n const [authOverride, setAuthOverride] = useState<AuthOverride | null>(null)\n const [authOverridePending, setAuthOverridePending] = useState(false)\n const [clientReady, setClientReady] = useState(false)\n const [email, setEmail] = useState('')\n const [tenantId, setTenantId] = useState<string | null>(null)\n const [tenantName, setTenantName] = useState<string | null>(null)\n const [tenantLoading, setTenantLoading] = useState(false)\n const [tenantInvalid, setTenantInvalid] = useState<string | null>(null)\n const showTenantInvalid = tenantId != null && tenantInvalid === tenantId\n const LoginFormSection = useRegisteredComponent<LoginFormSectionProps>(\n 'section:auth.login.form',\n LoginFormSectionDefault,\n )\n\n useEffect(() => {\n setClientReady(true)\n }, [])\n\n useEffect(() => {\n let cancelled = false\n void (async () => {\n try {\n const res = await apiCall<{ userId?: string }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: [] }),\n cache: 'no-store',\n })\n if (cancelled) return\n const activeUserId = typeof res.result?.userId === 'string' ? res.result.userId : ''\n if (!activeUserId) return\n const rawRedirect = searchParams.get('redirect') || ''\n let destination = '/backend'\n if (rawRedirect) {\n try {\n const resolved = new URL(rawRedirect, window.location.origin)\n if (\n resolved.origin === window.location.origin &&\n resolved.pathname.startsWith('/') &&\n !resolved.pathname.includes('//')\n ) {\n destination = resolved.pathname + resolved.search + resolved.hash\n }\n } catch {\n // fall back to /backend\n }\n }\n router.replace(destination)\n } catch {\n // ignore \u2014 leave login form usable on network failure\n }\n })()\n return () => { cancelled = true }\n }, [router, searchParams])\n\n useEffect(() => {\n const tenantParam = (searchParams.get('tenant') || '').trim()\n if (tenantParam) {\n setTenantId(tenantParam)\n window.localStorage.setItem(loginTenantKey, tenantParam)\n setTenantCookie(tenantParam)\n return\n }\n const storedTenant = window.localStorage.getItem(loginTenantKey) || readTenantCookie()\n if (storedTenant) {\n setTenantId(storedTenant)\n }\n }, [searchParams])\n\n useEffect(() => {\n if (!tenantId) {\n setTenantName(null)\n setTenantInvalid(null)\n return\n }\n if (tenantInvalid === tenantId) {\n setTenantName(null)\n setTenantLoading(false)\n return\n }\n let active = true\n setTenantLoading(true)\n setTenantInvalid(null)\n apiCall<{ ok: boolean; tenant?: { id: string; name: string }; error?: string }>(\n `/api/directory/tenants/lookup?tenantId=${encodeURIComponent(tenantId)}`,\n )\n .then(({ result }) => {\n if (!active) return\n if (result?.ok && result.tenant) {\n setTenantName(result.tenant.name)\n return\n }\n setTenantName(null)\n setTenantInvalid(tenantId)\n setError(null)\n })\n .catch(() => {\n if (!active) return\n setTenantName(null)\n setTenantInvalid(tenantId)\n setError(null)\n })\n .finally(() => {\n if (active) setTenantLoading(false)\n })\n return () => {\n active = false\n }\n }, [tenantId, translate])\n\n function handleClearTenant() {\n window.localStorage.removeItem(loginTenantKey)\n clearTenantCookie()\n setTenantId(null)\n setTenantName(null)\n setTenantInvalid(null)\n const params = new URLSearchParams(searchParams)\n params.delete('tenant')\n setError(null)\n const query = params.toString()\n router.replace(query ? `/login?${query}` : '/login')\n }\n\n async function onSubmit(e: React.FormEvent<HTMLFormElement>) {\n e.preventDefault()\n if (!clientReady || authOverridePending) {\n return\n }\n setError(null)\n if (authOverride) {\n authOverride.onSubmit()\n return\n }\n setSubmitting(true)\n try {\n const form = new FormData(e.currentTarget)\n if (requiredRoles.length) form.set('requireRole', requiredRoles.join(','))\n const redirectParam = searchParams.get('redirect')\n if (redirectParam) form.set('redirect', redirectParam)\n const res = await fetch('/api/auth/login', { method: 'POST', body: form })\n if (res.redirected) {\n clearAllOperations()\n notifyAuthIdentityChange()\n // NextResponse.redirect from API\n router.replace(res.url)\n return\n }\n if (!res.ok) {\n const fallback = (() => {\n if (res.status === 403) {\n return translate(\n 'auth.login.errors.permissionDenied',\n 'You do not have permission to access this area. Please contact your administrator.',\n )\n }\n if (res.status === 401 || res.status === 400) {\n return translate('auth.login.errors.invalidCredentials', 'Invalid email or password')\n }\n return translate('auth.login.errors.generic', 'An error occurred. Please try again.')\n })()\n const cloned = res.clone()\n let errorMessage = ''\n const contentType = res.headers.get('content-type') || ''\n if (contentType.includes('application/json')) {\n try {\n const data = await res.json()\n errorMessage = extractErrorMessage(data) || ''\n } catch {\n try {\n const text = await cloned.text()\n const trimmed = text.trim()\n if (trimmed && !looksLikeJsonString(trimmed)) {\n errorMessage = trimmed\n }\n } catch {\n errorMessage = ''\n }\n }\n } else {\n try {\n const text = await res.text()\n const trimmed = text.trim()\n if (trimmed && !looksLikeJsonString(trimmed)) {\n errorMessage = trimmed\n }\n } catch {\n errorMessage = ''\n }\n }\n setError(errorMessage || fallback)\n return\n }\n // In case API returns 200 with JSON\n const data = await res.json().catch(() => null) as LoginResponseEventDetail\n emitLoginResponseEvent(data)\n clearAllOperations()\n notifyAuthIdentityChange()\n if (data && typeof data.redirect === 'string' && data.redirect.length > 0) {\n router.replace(data.redirect)\n }\n } catch (err: unknown) {\n // Handle any errors thrown (e.g., network errors or thrown exceptions)\n const message = err instanceof Error ? err.message : ''\n setError(message || translate('auth.login.errors.generic', 'An error occurred. Please try again.'))\n } finally {\n setSubmitting(false)\n }\n }\n\n const loginFormContext = useMemo<LoginFormWidgetContext>(() => ({\n email,\n tenantId,\n searchParams,\n setAuthOverride,\n setAuthOverridePending,\n setError,\n }), [email, tenantId, searchParams])\n\n const formReady = clientReady && !authOverridePending\n\n return (\n <div className=\"min-h-svh flex items-center justify-center p-4\">\n <Card className=\"w-full max-w-sm\">\n <CardHeader className=\"flex flex-col items-center gap-4 text-center p-10\">\n <Image alt={translate('auth.login.logoAlt', 'Open Mercato logo')} src=\"/open-mercato.svg\" width={150} height={150} priority />\n <h1 className=\"text-2xl font-semibold\">{translate('auth.login.brandName', 'Open Mercato')}</h1>\n <CardDescription>{translate('auth.login.subtitle', 'Access your workspace')}</CardDescription>\n </CardHeader>\n <CardContent>\n <LoginFormSection>\n <form className=\"grid gap-3\" onSubmit={onSubmit} noValidate data-auth-ready={formReady ? '1' : '0'}>\n {tenantId ? (\n <input type=\"hidden\" name=\"tenantId\" value={tenantId} />\n ) : null}\n {!!translatedRoles.length && (\n <Alert variant=\"info\" className=\"text-center\">\n <AlertDescription>\n {translate(\n translatedRoles.length > 1 ? 'auth.login.requireRolesMessage' : 'auth.login.requireRoleMessage',\n translatedRoles.length > 1\n ? 'Access requires one of the following roles: {roles}'\n : 'Access requires role: {roles}',\n { roles: translatedRoles.join(', ') },\n )}\n </AlertDescription>\n </Alert>\n )}\n {!!translatedFeatures.length && (\n <Alert variant=\"info\" className=\"text-center\">\n <AlertDescription>\n {translate('auth.login.featureDenied', \"You don't have access to this feature ({feature}). Please contact your administrator.\", {\n feature: translatedFeatures.join(', '),\n })}\n </AlertDescription>\n </Alert>\n )}\n {showTenantInvalid ? (\n <div className=\"rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-xs text-red-700\">\n <div className=\"font-medium\">{translate('auth.login.errors.tenantInvalid', 'Tenant not found. Clear the tenant selection and try again.')}</div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-2 border-red-300 text-red-700\" onClick={handleClearTenant}>\n <X className=\"mr-2 size-4\" aria-hidden=\"true\" />\n {translate('auth.login.tenantClear', 'Clear')}\n </Button>\n </div>\n ) : tenantId ? (\n <div className=\"rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-center text-xs text-emerald-900\">\n <div className=\"font-medium\">\n {tenantLoading\n ? translate('auth.login.tenantLoading', 'Loading tenant details...')\n : translate('auth.login.tenantBanner', \"You're logging in to {tenant} tenant.\", {\n tenant: tenantName || tenantId,\n })}\n </div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-2 border-emerald-300 text-emerald-900\" onClick={handleClearTenant}>\n <X className=\"mr-2 size-4\" aria-hidden=\"true\" />\n {translate('auth.login.tenantClear', 'Clear')}\n </Button>\n </div>\n ) : null}\n {error && !showTenantInvalid && (\n <div className=\"rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-sm text-red-700\" role=\"alert\" aria-live=\"polite\">\n {error}\n </div>\n )}\n <div className=\"grid gap-1\">\n <Label htmlFor=\"email\">{t('auth.email')}</Label>\n <EmailInput\n id=\"email\"\n name=\"email\"\n required\n aria-invalid={!!error}\n onChange={(e) => setEmail(e.target.value)}\n onBlur={(e) => setEmail(e.target.value)}\n />\n </div>\n <InjectionSpot<LoginFormWidgetContext>\n spotId=\"auth.login:form\"\n context={loginFormContext}\n />\n {authOverride?.hidePassword ? null : (\n <div className=\"grid gap-1\">\n <Label htmlFor=\"password\">{t('auth.password')}</Label>\n <PasswordInput id=\"password\" name=\"password\" required={!authOverride} aria-invalid={!!error} autoComplete=\"current-password\" />\n </div>\n )}\n {!authOverride?.hideRememberMe && !authOverride?.hidePassword && (\n <label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <input type=\"checkbox\" name=\"remember\" className=\"accent-foreground\" />\n <span>{translate('auth.login.rememberMe', 'Remember me')}</span>\n </label>\n )}\n <Button type=\"submit\" disabled={submitting || !formReady} className=\"h-10 mt-2\">\n {submitting\n ? translate('auth.login.loading', 'Loading...')\n : authOverride\n ? authOverride.providerLabel\n : translate('auth.signIn', 'Sign in')}\n </Button>\n {!authOverride?.hideForgotPassword && (\n <div className=\"text-xs text-muted-foreground mt-2\">\n <Link className=\"underline\" href=\"/reset\">\n {translate('auth.login.forgotPassword', 'Forgot password?')}\n </Link>\n </div>\n )}\n </form>\n </LoginFormSection>\n </CardContent>\n </Card>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";AAqFS,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Card, CardContent, CardHeader, CardDescription } from '@open-mercato/ui/primitives/card'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { EmailInput } from '@open-mercato/ui/primitives/email-input'\nimport { PasswordInput } from '@open-mercato/ui/primitives/password-input'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport { clearAllOperations } from '@open-mercato/ui/backend/operations/store'\nimport { notifyAuthIdentityChange } from '@open-mercato/ui/backend/AuthSessionGuard'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { X } from 'lucide-react'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\nimport { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'\nimport type { AuthOverride, LoginFormWidgetContext } from './login-injection'\n\nconst loginTenantKey = 'om_login_tenant'\nconst loginTenantCookieMaxAge = 60 * 60 * 24 * 14\n\nfunction readTenantCookie() {\n if (typeof document === 'undefined') return null\n const entries = document.cookie.split(';')\n for (const entry of entries) {\n const [name, ...rest] = entry.trim().split('=')\n if (name === loginTenantKey) return decodeURIComponent(rest.join('='))\n }\n return null\n}\n\nfunction setTenantCookie(value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${loginTenantKey}=${encodeURIComponent(value)}; path=/; max-age=${loginTenantCookieMaxAge}; samesite=lax`\n}\n\nfunction clearTenantCookie() {\n if (typeof document === 'undefined') return\n document.cookie = `${loginTenantKey}=; path=/; max-age=0; samesite=lax`\n}\n\nfunction extractErrorMessage(payload: unknown): string | null {\n if (!payload) return null\n if (typeof payload === 'string') return payload\n if (Array.isArray(payload)) {\n for (const entry of payload) {\n const resolved = extractErrorMessage(entry)\n if (resolved) return resolved\n }\n return null\n }\n if (typeof payload === 'object') {\n const record = payload as Record<string, unknown>\n const candidates: unknown[] = [\n record.error,\n record.message,\n record.detail,\n record.details,\n record.description,\n ]\n for (const candidate of candidates) {\n const resolved = extractErrorMessage(candidate)\n if (resolved) return resolved\n }\n }\n return null\n}\n\nfunction looksLikeJsonString(value: string): boolean {\n const trimmed = value.trim()\n return trimmed.startsWith('{') || trimmed.startsWith('[')\n}\n\ntype LoginResponseEventDetail = Record<string, unknown> | null\n\ntype LoginFormSectionProps = {\n children: ReactNode\n}\n\nfunction LoginFormSectionDefault({ children }: LoginFormSectionProps) {\n return <>{children}</>\n}\n\nfunction emitLoginResponseEvent(detail: LoginResponseEventDetail) {\n if (typeof window === 'undefined') return\n window.dispatchEvent(new CustomEvent('om:auth:login-response', { detail }))\n}\n\nexport default function LoginPage() {\n const t = useT()\n const translate = useCallback(\n (key: string, fallback: string, params?: Record<string, string | number>) =>\n translateWithFallback(t, key, fallback, params),\n [t],\n )\n const router = useRouter()\n const searchParams = useSearchParams()\n const requireRole = (searchParams.get('requireRole') || searchParams.get('role') || '').trim()\n const requireFeature = (searchParams.get('requireFeature') || '').trim()\n const requiredRoles = requireRole ? requireRole.split(',').map((value) => value.trim()).filter(Boolean) : []\n const requiredFeatures = requireFeature ? requireFeature.split(',').map((value) => value.trim()).filter(Boolean) : []\n const translatedRoles = requiredRoles.map((role) => translate(`auth.roles.${role}`, role))\n const translatedFeatures = requiredFeatures.map((feature) => translate(`features.${feature}`, feature))\n const [error, setError] = useState<string | null>(null)\n const [submitting, setSubmitting] = useState(false)\n const [authOverride, setAuthOverride] = useState<AuthOverride | null>(null)\n const [authOverridePending, setAuthOverridePending] = useState(false)\n const [clientReady, setClientReady] = useState(false)\n const [activeAuthenticatedUser, setActiveAuthenticatedUser] = useState(false)\n const [email, setEmail] = useState('')\n const [tenantId, setTenantId] = useState<string | null>(null)\n const [tenantName, setTenantName] = useState<string | null>(null)\n const [tenantLoading, setTenantLoading] = useState(false)\n const [tenantInvalid, setTenantInvalid] = useState<string | null>(null)\n const showTenantInvalid = tenantId != null && tenantInvalid === tenantId\n const LoginFormSection = useRegisteredComponent<LoginFormSectionProps>(\n 'section:auth.login.form',\n LoginFormSectionDefault,\n )\n\n useEffect(() => {\n setClientReady(true)\n }, [])\n\n useEffect(() => {\n let cancelled = false\n const hasAclChallenge = requiredFeatures.length > 0 || requiredRoles.length > 0\n void (async () => {\n try {\n const res = await apiCall<{ userId?: string }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: [] }),\n cache: 'no-store',\n })\n if (cancelled) return\n const activeUserId = typeof res.result?.userId === 'string' ? res.result.userId : ''\n if (!activeUserId) return\n setActiveAuthenticatedUser(true)\n // When a feature/role challenge is present in the URL, the user already\n // failed an ACL check while authenticated. Auto-redirecting back to\n // `redirect` would re-trigger the same 403 and re-bounce here,\n // producing an infinite loop (see GH #2070). Stay on the login page so\n // the access-denied banner is visible.\n if (hasAclChallenge) return\n const rawRedirect = searchParams.get('redirect') || ''\n let destination = '/backend'\n if (rawRedirect) {\n try {\n const resolved = new URL(rawRedirect, window.location.origin)\n if (\n resolved.origin === window.location.origin &&\n resolved.pathname.startsWith('/') &&\n !resolved.pathname.includes('//')\n ) {\n destination = resolved.pathname + resolved.search + resolved.hash\n }\n } catch {\n // fall back to /backend\n }\n }\n router.replace(destination)\n } catch {\n // ignore \u2014 leave login form usable on network failure\n }\n })()\n return () => { cancelled = true }\n }, [router, searchParams, requiredFeatures.length, requiredRoles.length])\n\n useEffect(() => {\n const tenantParam = (searchParams.get('tenant') || '').trim()\n if (tenantParam) {\n setTenantId(tenantParam)\n window.localStorage.setItem(loginTenantKey, tenantParam)\n setTenantCookie(tenantParam)\n return\n }\n const storedTenant = window.localStorage.getItem(loginTenantKey) || readTenantCookie()\n if (storedTenant) {\n setTenantId(storedTenant)\n }\n }, [searchParams])\n\n useEffect(() => {\n if (!tenantId) {\n setTenantName(null)\n setTenantInvalid(null)\n return\n }\n if (tenantInvalid === tenantId) {\n setTenantName(null)\n setTenantLoading(false)\n return\n }\n let active = true\n setTenantLoading(true)\n setTenantInvalid(null)\n apiCall<{ ok: boolean; tenant?: { id: string; name: string }; error?: string }>(\n `/api/directory/tenants/lookup?tenantId=${encodeURIComponent(tenantId)}`,\n )\n .then(({ result }) => {\n if (!active) return\n if (result?.ok && result.tenant) {\n setTenantName(result.tenant.name)\n return\n }\n setTenantName(null)\n setTenantInvalid(tenantId)\n setError(null)\n })\n .catch(() => {\n if (!active) return\n setTenantName(null)\n setTenantInvalid(tenantId)\n setError(null)\n })\n .finally(() => {\n if (active) setTenantLoading(false)\n })\n return () => {\n active = false\n }\n }, [tenantId, translate])\n\n function handleClearTenant() {\n window.localStorage.removeItem(loginTenantKey)\n clearTenantCookie()\n setTenantId(null)\n setTenantName(null)\n setTenantInvalid(null)\n const params = new URLSearchParams(searchParams)\n params.delete('tenant')\n setError(null)\n const query = params.toString()\n router.replace(query ? `/login?${query}` : '/login')\n }\n\n async function onSubmit(e: React.FormEvent<HTMLFormElement>) {\n e.preventDefault()\n if (!clientReady || authOverridePending) {\n return\n }\n setError(null)\n if (authOverride) {\n authOverride.onSubmit()\n return\n }\n setSubmitting(true)\n try {\n const form = new FormData(e.currentTarget)\n if (requiredRoles.length) form.set('requireRole', requiredRoles.join(','))\n const redirectParam = searchParams.get('redirect')\n if (redirectParam) form.set('redirect', redirectParam)\n const res = await fetch('/api/auth/login', { method: 'POST', body: form })\n if (res.redirected) {\n clearAllOperations()\n notifyAuthIdentityChange()\n // NextResponse.redirect from API\n router.replace(res.url)\n return\n }\n if (!res.ok) {\n const fallback = (() => {\n if (res.status === 403) {\n return translate(\n 'auth.login.errors.permissionDenied',\n 'You do not have permission to access this area. Please contact your administrator.',\n )\n }\n if (res.status === 401 || res.status === 400) {\n return translate('auth.login.errors.invalidCredentials', 'Invalid email or password')\n }\n return translate('auth.login.errors.generic', 'An error occurred. Please try again.')\n })()\n const cloned = res.clone()\n let errorMessage = ''\n const contentType = res.headers.get('content-type') || ''\n if (contentType.includes('application/json')) {\n try {\n const data = await res.json()\n errorMessage = extractErrorMessage(data) || ''\n } catch {\n try {\n const text = await cloned.text()\n const trimmed = text.trim()\n if (trimmed && !looksLikeJsonString(trimmed)) {\n errorMessage = trimmed\n }\n } catch {\n errorMessage = ''\n }\n }\n } else {\n try {\n const text = await res.text()\n const trimmed = text.trim()\n if (trimmed && !looksLikeJsonString(trimmed)) {\n errorMessage = trimmed\n }\n } catch {\n errorMessage = ''\n }\n }\n setError(errorMessage || fallback)\n return\n }\n // In case API returns 200 with JSON\n const data = await res.json().catch(() => null) as LoginResponseEventDetail\n emitLoginResponseEvent(data)\n clearAllOperations()\n notifyAuthIdentityChange()\n if (data && typeof data.redirect === 'string' && data.redirect.length > 0) {\n router.replace(data.redirect)\n }\n } catch (err: unknown) {\n // Handle any errors thrown (e.g., network errors or thrown exceptions)\n const message = err instanceof Error ? err.message : ''\n setError(message || translate('auth.login.errors.generic', 'An error occurred. Please try again.'))\n } finally {\n setSubmitting(false)\n }\n }\n\n const loginFormContext = useMemo<LoginFormWidgetContext>(() => ({\n email,\n tenantId,\n searchParams,\n setAuthOverride,\n setAuthOverridePending,\n setError,\n }), [email, tenantId, searchParams])\n\n const formReady = clientReady && !authOverridePending\n\n return (\n <div className=\"min-h-svh flex items-center justify-center p-4\">\n <Card className=\"w-full max-w-sm\">\n <CardHeader className=\"flex flex-col items-center gap-4 text-center p-10\">\n <Image alt={translate('auth.login.logoAlt', 'Open Mercato logo')} src=\"/open-mercato.svg\" width={150} height={150} priority />\n <h1 className=\"text-2xl font-semibold\">{translate('auth.login.brandName', 'Open Mercato')}</h1>\n <CardDescription>{translate('auth.login.subtitle', 'Access your workspace')}</CardDescription>\n </CardHeader>\n <CardContent>\n <LoginFormSection>\n <form className=\"grid gap-3\" onSubmit={onSubmit} noValidate data-auth-ready={formReady ? '1' : '0'}>\n {tenantId ? (\n <input type=\"hidden\" name=\"tenantId\" value={tenantId} />\n ) : null}\n {!!translatedRoles.length && (\n <Alert variant=\"info\" className=\"text-center\">\n <AlertDescription>\n {translate(\n translatedRoles.length > 1 ? 'auth.login.requireRolesMessage' : 'auth.login.requireRoleMessage',\n translatedRoles.length > 1\n ? 'Access requires one of the following roles: {roles}'\n : 'Access requires role: {roles}',\n { roles: translatedRoles.join(', ') },\n )}\n </AlertDescription>\n </Alert>\n )}\n {!!translatedFeatures.length && (\n <Alert variant=\"info\" className=\"text-center\">\n <AlertDescription>\n {translate('auth.login.featureDenied', \"You don't have access to this feature ({feature}). Please contact your administrator.\", {\n feature: translatedFeatures.join(', '),\n })}\n </AlertDescription>\n </Alert>\n )}\n {activeAuthenticatedUser && (translatedRoles.length || translatedFeatures.length) ? (\n <div className=\"flex justify-center\" data-testid=\"login-return-dashboard\">\n <Button asChild type=\"button\" variant=\"outline\" size=\"sm\">\n <Link href=\"/backend\">\n {translate('auth.accessDenied.dashboard', 'Go to Dashboard')}\n </Link>\n </Button>\n </div>\n ) : null}\n {showTenantInvalid ? (\n <div className=\"rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-xs text-red-700\">\n <div className=\"font-medium\">{translate('auth.login.errors.tenantInvalid', 'Tenant not found. Clear the tenant selection and try again.')}</div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-2 border-red-300 text-red-700\" onClick={handleClearTenant}>\n <X className=\"mr-2 size-4\" aria-hidden=\"true\" />\n {translate('auth.login.tenantClear', 'Clear')}\n </Button>\n </div>\n ) : tenantId ? (\n <div className=\"rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-center text-xs text-emerald-900\">\n <div className=\"font-medium\">\n {tenantLoading\n ? translate('auth.login.tenantLoading', 'Loading tenant details...')\n : translate('auth.login.tenantBanner', \"You're logging in to {tenant} tenant.\", {\n tenant: tenantName || tenantId,\n })}\n </div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-2 border-emerald-300 text-emerald-900\" onClick={handleClearTenant}>\n <X className=\"mr-2 size-4\" aria-hidden=\"true\" />\n {translate('auth.login.tenantClear', 'Clear')}\n </Button>\n </div>\n ) : null}\n {error && !showTenantInvalid && (\n <div className=\"rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-sm text-red-700\" role=\"alert\" aria-live=\"polite\">\n {error}\n </div>\n )}\n <div className=\"grid gap-1\">\n <Label htmlFor=\"email\">{t('auth.email')}</Label>\n <EmailInput\n id=\"email\"\n name=\"email\"\n required\n aria-invalid={!!error}\n onChange={(e) => setEmail(e.target.value)}\n onBlur={(e) => setEmail(e.target.value)}\n />\n </div>\n <InjectionSpot<LoginFormWidgetContext>\n spotId=\"auth.login:form\"\n context={loginFormContext}\n />\n {authOverride?.hidePassword ? null : (\n <div className=\"grid gap-1\">\n <Label htmlFor=\"password\">{t('auth.password')}</Label>\n <PasswordInput id=\"password\" name=\"password\" required={!authOverride} aria-invalid={!!error} autoComplete=\"current-password\" />\n </div>\n )}\n {!authOverride?.hideRememberMe && !authOverride?.hidePassword && (\n <label className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <input type=\"checkbox\" name=\"remember\" className=\"accent-foreground\" />\n <span>{translate('auth.login.rememberMe', 'Remember me')}</span>\n </label>\n )}\n <Button type=\"submit\" disabled={submitting || !formReady} className=\"h-10 mt-2\">\n {submitting\n ? translate('auth.login.loading', 'Loading...')\n : authOverride\n ? authOverride.providerLabel\n : translate('auth.signIn', 'Sign in')}\n </Button>\n {!authOverride?.hideForgotPassword && (\n <div className=\"text-xs text-muted-foreground mt-2\">\n <Link className=\"underline\" href=\"/reset\">\n {translate('auth.login.forgotPassword', 'Forgot password?')}\n </Link>\n </div>\n )}\n </form>\n </LoginFormSection>\n </CardContent>\n </Card>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["data"]
|
|
7
7
|
}
|
|
@@ -57,6 +57,12 @@ class RbacService {
|
|
|
57
57
|
hasAllFeatures(required, granted) {
|
|
58
58
|
return sharedHasAllFeatures(required, granted);
|
|
59
59
|
}
|
|
60
|
+
roleAclAllowsOrganization(acl, organizationId) {
|
|
61
|
+
if (!organizationId) return true;
|
|
62
|
+
const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null;
|
|
63
|
+
if (!organizations || !organizations.length || organizations.includes("__all__")) return true;
|
|
64
|
+
return organizations.includes(organizationId);
|
|
65
|
+
}
|
|
60
66
|
getCacheKey(userId, scope) {
|
|
61
67
|
return `rbac:${userId}:${scope.tenantId || "null"}:${scope.organizationId || "null"}`;
|
|
62
68
|
}
|
|
@@ -326,6 +332,28 @@ class RbacService {
|
|
|
326
332
|
const acl = await this.loadAcl(userId, scope);
|
|
327
333
|
return Array.isArray(acl.features) ? acl.features : [];
|
|
328
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Checks whether any tenant role grants a feature.
|
|
337
|
+
*
|
|
338
|
+
* This supports non-user runtimes such as scheduler workers that execute with
|
|
339
|
+
* tenant scope but without an authenticated user.
|
|
340
|
+
*/
|
|
341
|
+
async tenantHasFeature(tenantId, feature, opts) {
|
|
342
|
+
if (!tenantId || !feature) return false;
|
|
343
|
+
const enabledIds = getEnabledModuleIds();
|
|
344
|
+
if (enabledIds.length && !enabledIds.includes(getOwningModuleId(feature))) return false;
|
|
345
|
+
const em = this.em.fork();
|
|
346
|
+
const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null }, {});
|
|
347
|
+
const list = Array.isArray(roleAcls) ? roleAcls : [];
|
|
348
|
+
const organizationId = opts?.organizationId ?? null;
|
|
349
|
+
for (const acl of list) {
|
|
350
|
+
if (!this.roleAclAllowsOrganization(acl, organizationId)) continue;
|
|
351
|
+
if (acl.isSuperAdmin) return true;
|
|
352
|
+
const grants = Array.isArray(acl.featuresJson) ? acl.featuresJson : [];
|
|
353
|
+
if (this.hasAllFeatures([feature], filterGrantsByEnabledModules(grants))) return true;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
329
357
|
/**
|
|
330
358
|
* Checks if a user has all required features within a given scope.
|
|
331
359
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/auth/services/rbacService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'\nimport { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchFeature as sharedMatchFeature, hasAllFeatures as sharedHasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { filterGrantsByEnabledModules, getOwningModuleId, getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'\n\ninterface AclData {\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n}\n\nfunction isAclData(value: unknown): value is AclData {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<AclData>\n if (typeof record.isSuperAdmin !== 'boolean') return false\n if (!Array.isArray(record.features) || record.features.some((feature) => typeof feature !== 'string')) return false\n if (record.organizations !== null && record.organizations !== undefined) {\n if (!Array.isArray(record.organizations)) return false\n if (record.organizations.some((org) => typeof org !== 'string')) return false\n }\n return true\n}\n\nexport class RbacService {\n private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default\n private cache: CacheStrategy | null = null\n private globalSuperAdminCache = new Map<string, boolean>()\n\n constructor(private em: EntityManager, cache?: CacheStrategy) {\n this.cache = cache || null\n }\n\n /**\n * Set cache TTL in milliseconds\n * @param ttlMs - Time to live in milliseconds\n */\n setCacheTtl(ttlMs: number) {\n this.cacheTtlMs = ttlMs\n }\n\n /**\n * Checks if a required feature is satisfied by a granted feature permission.\n * \n * Wildcard patterns:\n * - `*` (global wildcard): Grants access to all features\n * - `prefix.*` (module wildcard): Grants access to all features starting with `prefix.`\n * and also the exact prefix itself (e.g., `entities.*` matches both `entities` and `entities.records.view`)\n * - Exact match: Feature must match exactly (e.g., `users.view` only matches `users.view`)\n * \n * @param required - The feature being requested (e.g., 'entities.records.view')\n * @param granted - The feature permission granted (e.g., 'entities.*' or '*')\n * @returns true if the granted permission satisfies the required feature\n * \n * @example\n * matchFeature('users.view', '*') // true - global wildcard\n * matchFeature('entities.records.view', 'entities.*') // true - module wildcard\n * matchFeature('entities', 'entities.*') // true - exact prefix match\n * matchFeature('users.view', 'entities.*') // false - different module\n * matchFeature('users.view', 'users.view') // true - exact match\n */\n private matchFeature(required: string, granted: string): boolean {\n return sharedMatchFeature(required, granted)\n }\n\n public hasAllFeatures(required: string[], granted: string[]): boolean {\n return sharedHasAllFeatures(required, granted)\n }\n\n private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {\n return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`\n }\n\n private getUserTag(userId: string): string {\n return `rbac:user:${userId}`\n }\n\n private getTenantTag(tenantId: string): string {\n return `rbac:tenant:${tenantId}`\n }\n\n private getOrganizationTag(organizationId: string): string {\n return `rbac:org:${organizationId}`\n }\n\n private async getFromCache(cacheKey: string): Promise<AclData | null> {\n if (!this.cache) return null\n const cached = await this.cache.get(cacheKey)\n if (!cached) return null\n return isAclData(cached) ? cached : null\n }\n\n private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {\n if (!this.cache) return\n\n const tags = [\n this.getUserTag(userId),\n 'rbac:all'\n ]\n\n if (scope.tenantId) {\n tags.push(this.getTenantTag(scope.tenantId))\n }\n\n if (scope.organizationId) {\n tags.push(this.getOrganizationTag(scope.organizationId))\n }\n\n await this.cache.set(cacheKey, data, {\n ttl: this.cacheTtlMs,\n tags\n })\n }\n\n /**\n * Invalidates cached ACL data for a specific user across all tenants and organizations.\n * Call this when a user's roles or user-specific ACL is modified.\n * \n * @param userId - The ID of the user whose cache should be invalidated\n */\n async invalidateUserCache(userId: string): Promise<void> {\n this.globalSuperAdminCache.delete(userId)\n await this.deleteCacheByTags([this.getUserTag(userId)])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific tenant.\n * Call this when a role's ACL is modified, since roles are tenant-scoped\n * and affect all users in that tenant who have that role.\n * \n * @param tenantId - The ID of the tenant whose cache should be invalidated\n */\n async invalidateTenantCache(tenantId: string): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags([this.getTenantTag(tenantId)], [tenantId])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific organization.\n * Call this when organization-level permissions or visibility changes.\n * \n * @param organizationId - The ID of the organization whose cache should be invalidated\n */\n async invalidateOrganizationCache(organizationId: string): Promise<void> {\n await this.deleteCacheByTags([this.getOrganizationTag(organizationId)])\n }\n\n /**\n * Clears all cached ACL data.\n * Use this for bulk operations or system-wide ACL changes.\n */\n async invalidateAllCache(): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags(['rbac:all'])\n }\n\n private async deleteCacheByTags(tags: string[], tenantHints?: Array<string | null>): Promise<void> {\n if (!this.cache) return\n const contexts = new Set<string | null>()\n const current = getCurrentCacheTenant()\n contexts.add(current ?? null)\n contexts.add(null)\n if (Array.isArray(tenantHints)) {\n for (const hint of tenantHints) {\n contexts.add(hint ?? null)\n }\n }\n for (const ctx of contexts) {\n if (ctx === current) {\n await this.cache.deleteByTags(tags)\n } else {\n await runWithCacheTenant(ctx, async () => {\n await this.cache!.deleteByTags(tags)\n })\n }\n }\n }\n\n private async isGlobalSuperAdmin(userId: string): Promise<boolean> {\n if (this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!\n const em = this.em.fork()\n const userSuper = await em.findOne(UserAcl, { user: userId as any, isSuperAdmin: true })\n if (userSuper && (userSuper as any).isSuperAdmin) {\n this.globalSuperAdminCache.set(userId, true)\n return true\n }\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any },\n { populate: ['role'] },\n { tenantId: null, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n if (!linkList.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleIds = Array.from(new Set(linkList.map((link) => {\n const role = link.role as any\n return role?.id ? String(role.id) : null\n }).filter((id): id is string => typeof id === 'string' && id.length > 0)))\n if (!roleIds.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleSuper = await em.findOne(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)\n const result = !!(roleSuper && (roleSuper as any).isSuperAdmin)\n this.globalSuperAdminCache.set(userId, result)\n return result\n }\n\n /**\n * Loads the Access Control List (ACL) for a user within a given scope.\n * \n * The ACL resolution follows this priority:\n * 1. Per-user ACL (UserAcl) - if exists, use it exclusively\n * 2. Aggregated role ACLs (RoleAcl) - combine permissions from all user's roles\n * \n * Results are cached for performance (default 5 minutes TTL).\n * Cache is automatically invalidated when ACL-related data changes.\n * \n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns An object containing:\n * - isSuperAdmin: If true, user has unrestricted access to all features\n * - features: Array of feature strings (may include wildcards like 'entities.*')\n * - organizations: Array of organization IDs user can access, or null for all organizations\n * \n * @example\n * const acl = await rbacService.loadAcl('user-123', { tenantId: 'tenant-1', organizationId: 'org-1' })\n * // Returns: { isSuperAdmin: false, features: ['users.view', 'entities.*'], organizations: ['org-1', 'org-2'] }\n */\n async loadAcl(userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }> {\n const cacheKey = this.getCacheKey(userId, scope)\n const cached = await this.getFromCache(cacheKey)\n if (cached) return cached\n\n if (!userId.startsWith('api_key:')) {\n if (await this.isGlobalSuperAdmin(userId)) {\n const result = { isSuperAdmin: true, features: ['*'], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n }\n\n if (userId.startsWith('api_key:')) {\n const apiKeyId = userId.slice('api_key:'.length)\n const em = this.em.fork()\n const key = await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })\n if (!key || (key.expiresAt && key.expiresAt.getTime() < Date.now())) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || key.tenantId || null\n const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = key.organizationId ? [key.organizationId] : null\n if (tenantId && roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)\n for (const acl of racls) {\n isSuper = isSuper || !!acl.isSuperAdmin\n if (Array.isArray(acl.featuresJson)) {\n for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)\n }\n if (organizations !== null) {\n if (acl.organizationsJson == null) {\n organizations = null\n } else if (Array.isArray(acl.organizationsJson) && acl.organizationsJson.includes('__all__')) {\n organizations = null\n } else {\n organizations = Array.from(new Set([...(organizations || []), ...acl.organizationsJson]))\n }\n }\n }\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Use a forked EntityManager to avoid inheriting an aborted transaction from callers\n const em = this.em.fork()\n const user = await em.findOne(User, { id: userId })\n if (!user) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || user.tenantId || null\n const orgId = scope.organizationId || user.organizationId || null\n\n if (!tenantId) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Per-user ACL first\n const uacl = await em.findOne(UserAcl, { user: userId as any, tenantId })\n if (uacl) {\n const result = {\n isSuperAdmin: !!uacl.isSuperAdmin,\n features: Array.isArray(uacl.featuresJson) ? (uacl.featuresJson as string[]) : [],\n organizations: Array.isArray(uacl.organizationsJson) ? (uacl.organizationsJson as string[]) : null,\n }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Aggregate role ACLs\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: orgId },\n )\n const linkList = Array.isArray(links) ? links : []\n const roleIds = linkList.map((l) => (l.role as any)?.id).filter(Boolean)\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = []\n if (roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})\n const roleAcls = Array.isArray(racls) ? racls : []\n for (const r of roleAcls) {\n isSuper = isSuper || !!r.isSuperAdmin\n if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)\n if (organizations !== null) {\n if (r.organizationsJson == null) organizations = null\n else if (Array.isArray(r.organizationsJson) && r.organizationsJson.includes('__all__')) organizations = null\n else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))\n }\n }\n }\n if (organizations && orgId && !organizations.includes(orgId) && !organizations.includes('__all__')) {\n // Out-of-scope org; caller will enforce\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n /**\n * Returns the user's granted feature strings for a given scope.\n *\n * Used by infrastructure that needs the raw grant list rather than a yes/no\n * authorization check (for example response enrichers gating themselves with\n * `features: [...]`). Callers MUST apply wildcard-aware matching against the\n * returned array \u2014 grants like `module.*` or `*` are part of the ACL contract.\n *\n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns Array of feature strings (may include wildcards); empty array when\n * the user has no grants in scope\n */\n async getGrantedFeatures(\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<string[]> {\n const acl = await this.loadAcl(userId, scope)\n return Array.isArray(acl.features) ? acl.features : []\n }\n\n /**\n * Checks if a user has all required features within a given scope.\n *\n * This is the primary authorization check method used throughout the application.\n * It combines feature checking with organization visibility validation.\n *\n * Authorization logic:\n * 1. No features required \u2192 always returns true\n * 2. User is super admin \u2192 always returns true\n * 3. Organization restriction check: If the user's ACL has a restricted organization list\n * and the requested organization is not in that list \u2192 returns false\n * 4. Feature matching: User must have all required features (supports wildcards)\n *\n * @param userId - The ID of the user\n * @param required - Array of feature strings to check (e.g., ['users.view', 'users.edit'])\n * @param scope - The tenant and organization context for authorization\n * @returns true if the user has all required features and organization access, false otherwise\n *\n * @example\n * // Check if user can view and edit users\n * const canManageUsers = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['users.view', 'users.edit'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n *\n * @example\n * // Check with wildcard features\n * const canAccessEntities = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['entities.records.view'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * // Returns true if user has 'entities.*', '*', or 'entities.records.view'\n */\n async userHasAllFeatures(userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean> {\n if (!required.length) return true\n const acl = await this.loadAcl(userId, scope)\n if (acl.isSuperAdmin) {\n const enabledIds = getEnabledModuleIds()\n if (!enabledIds.length) return true\n const enabledSet = new Set(enabledIds)\n return required.every((feature) => enabledSet.has(getOwningModuleId(feature)))\n }\n if (acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) return false\n return this.hasAllFeatures(required, filterGrantsByEnabledModules(acl.features))\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'\nimport { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { matchFeature as sharedMatchFeature, hasAllFeatures as sharedHasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { filterGrantsByEnabledModules, getOwningModuleId, getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'\n\ninterface AclData {\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n}\n\nfunction isAclData(value: unknown): value is AclData {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<AclData>\n if (typeof record.isSuperAdmin !== 'boolean') return false\n if (!Array.isArray(record.features) || record.features.some((feature) => typeof feature !== 'string')) return false\n if (record.organizations !== null && record.organizations !== undefined) {\n if (!Array.isArray(record.organizations)) return false\n if (record.organizations.some((org) => typeof org !== 'string')) return false\n }\n return true\n}\n\nexport class RbacService {\n private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default\n private cache: CacheStrategy | null = null\n private globalSuperAdminCache = new Map<string, boolean>()\n\n constructor(private em: EntityManager, cache?: CacheStrategy) {\n this.cache = cache || null\n }\n\n /**\n * Set cache TTL in milliseconds\n * @param ttlMs - Time to live in milliseconds\n */\n setCacheTtl(ttlMs: number) {\n this.cacheTtlMs = ttlMs\n }\n\n /**\n * Checks if a required feature is satisfied by a granted feature permission.\n * \n * Wildcard patterns:\n * - `*` (global wildcard): Grants access to all features\n * - `prefix.*` (module wildcard): Grants access to all features starting with `prefix.`\n * and also the exact prefix itself (e.g., `entities.*` matches both `entities` and `entities.records.view`)\n * - Exact match: Feature must match exactly (e.g., `users.view` only matches `users.view`)\n * \n * @param required - The feature being requested (e.g., 'entities.records.view')\n * @param granted - The feature permission granted (e.g., 'entities.*' or '*')\n * @returns true if the granted permission satisfies the required feature\n * \n * @example\n * matchFeature('users.view', '*') // true - global wildcard\n * matchFeature('entities.records.view', 'entities.*') // true - module wildcard\n * matchFeature('entities', 'entities.*') // true - exact prefix match\n * matchFeature('users.view', 'entities.*') // false - different module\n * matchFeature('users.view', 'users.view') // true - exact match\n */\n private matchFeature(required: string, granted: string): boolean {\n return sharedMatchFeature(required, granted)\n }\n\n public hasAllFeatures(required: string[], granted: string[]): boolean {\n return sharedHasAllFeatures(required, granted)\n }\n\n private roleAclAllowsOrganization(acl: RoleAcl, organizationId: string | null | undefined): boolean {\n if (!organizationId) return true\n const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null\n if (!organizations || !organizations.length || organizations.includes('__all__')) return true\n return organizations.includes(organizationId)\n }\n\n private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {\n return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`\n }\n\n private getUserTag(userId: string): string {\n return `rbac:user:${userId}`\n }\n\n private getTenantTag(tenantId: string): string {\n return `rbac:tenant:${tenantId}`\n }\n\n private getOrganizationTag(organizationId: string): string {\n return `rbac:org:${organizationId}`\n }\n\n private async getFromCache(cacheKey: string): Promise<AclData | null> {\n if (!this.cache) return null\n const cached = await this.cache.get(cacheKey)\n if (!cached) return null\n return isAclData(cached) ? cached : null\n }\n\n private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {\n if (!this.cache) return\n\n const tags = [\n this.getUserTag(userId),\n 'rbac:all'\n ]\n\n if (scope.tenantId) {\n tags.push(this.getTenantTag(scope.tenantId))\n }\n\n if (scope.organizationId) {\n tags.push(this.getOrganizationTag(scope.organizationId))\n }\n\n await this.cache.set(cacheKey, data, {\n ttl: this.cacheTtlMs,\n tags\n })\n }\n\n /**\n * Invalidates cached ACL data for a specific user across all tenants and organizations.\n * Call this when a user's roles or user-specific ACL is modified.\n * \n * @param userId - The ID of the user whose cache should be invalidated\n */\n async invalidateUserCache(userId: string): Promise<void> {\n this.globalSuperAdminCache.delete(userId)\n await this.deleteCacheByTags([this.getUserTag(userId)])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific tenant.\n * Call this when a role's ACL is modified, since roles are tenant-scoped\n * and affect all users in that tenant who have that role.\n * \n * @param tenantId - The ID of the tenant whose cache should be invalidated\n */\n async invalidateTenantCache(tenantId: string): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags([this.getTenantTag(tenantId)], [tenantId])\n }\n\n /**\n * Invalidates cached ACL data for all users within a specific organization.\n * Call this when organization-level permissions or visibility changes.\n * \n * @param organizationId - The ID of the organization whose cache should be invalidated\n */\n async invalidateOrganizationCache(organizationId: string): Promise<void> {\n await this.deleteCacheByTags([this.getOrganizationTag(organizationId)])\n }\n\n /**\n * Clears all cached ACL data.\n * Use this for bulk operations or system-wide ACL changes.\n */\n async invalidateAllCache(): Promise<void> {\n this.globalSuperAdminCache.clear()\n await this.deleteCacheByTags(['rbac:all'])\n }\n\n private async deleteCacheByTags(tags: string[], tenantHints?: Array<string | null>): Promise<void> {\n if (!this.cache) return\n const contexts = new Set<string | null>()\n const current = getCurrentCacheTenant()\n contexts.add(current ?? null)\n contexts.add(null)\n if (Array.isArray(tenantHints)) {\n for (const hint of tenantHints) {\n contexts.add(hint ?? null)\n }\n }\n for (const ctx of contexts) {\n if (ctx === current) {\n await this.cache.deleteByTags(tags)\n } else {\n await runWithCacheTenant(ctx, async () => {\n await this.cache!.deleteByTags(tags)\n })\n }\n }\n }\n\n private async isGlobalSuperAdmin(userId: string): Promise<boolean> {\n if (this.globalSuperAdminCache.has(userId)) return this.globalSuperAdminCache.get(userId)!\n const em = this.em.fork()\n const userSuper = await em.findOne(UserAcl, { user: userId as any, isSuperAdmin: true })\n if (userSuper && (userSuper as any).isSuperAdmin) {\n this.globalSuperAdminCache.set(userId, true)\n return true\n }\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any },\n { populate: ['role'] },\n { tenantId: null, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n if (!linkList.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleIds = Array.from(new Set(linkList.map((link) => {\n const role = link.role as any\n return role?.id ? String(role.id) : null\n }).filter((id): id is string => typeof id === 'string' && id.length > 0)))\n if (!roleIds.length) {\n this.globalSuperAdminCache.set(userId, false)\n return false\n }\n const roleSuper = await em.findOne(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)\n const result = !!(roleSuper && (roleSuper as any).isSuperAdmin)\n this.globalSuperAdminCache.set(userId, result)\n return result\n }\n\n /**\n * Loads the Access Control List (ACL) for a user within a given scope.\n * \n * The ACL resolution follows this priority:\n * 1. Per-user ACL (UserAcl) - if exists, use it exclusively\n * 2. Aggregated role ACLs (RoleAcl) - combine permissions from all user's roles\n * \n * Results are cached for performance (default 5 minutes TTL).\n * Cache is automatically invalidated when ACL-related data changes.\n * \n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns An object containing:\n * - isSuperAdmin: If true, user has unrestricted access to all features\n * - features: Array of feature strings (may include wildcards like 'entities.*')\n * - organizations: Array of organization IDs user can access, or null for all organizations\n * \n * @example\n * const acl = await rbacService.loadAcl('user-123', { tenantId: 'tenant-1', organizationId: 'org-1' })\n * // Returns: { isSuperAdmin: false, features: ['users.view', 'entities.*'], organizations: ['org-1', 'org-2'] }\n */\n async loadAcl(userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }> {\n const cacheKey = this.getCacheKey(userId, scope)\n const cached = await this.getFromCache(cacheKey)\n if (cached) return cached\n\n if (!userId.startsWith('api_key:')) {\n if (await this.isGlobalSuperAdmin(userId)) {\n const result = { isSuperAdmin: true, features: ['*'], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n }\n\n if (userId.startsWith('api_key:')) {\n const apiKeyId = userId.slice('api_key:'.length)\n const em = this.em.fork()\n const key = await em.findOne(ApiKey, { id: apiKeyId, deletedAt: null })\n if (!key || (key.expiresAt && key.expiresAt.getTime() < Date.now())) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || key.tenantId || null\n const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = key.organizationId ? [key.organizationId] : null\n if (tenantId && roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)\n for (const acl of racls) {\n isSuper = isSuper || !!acl.isSuperAdmin\n if (Array.isArray(acl.featuresJson)) {\n for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)\n }\n if (organizations !== null) {\n if (acl.organizationsJson == null) {\n organizations = null\n } else if (Array.isArray(acl.organizationsJson) && acl.organizationsJson.includes('__all__')) {\n organizations = null\n } else {\n organizations = Array.from(new Set([...(organizations || []), ...acl.organizationsJson]))\n }\n }\n }\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Use a forked EntityManager to avoid inheriting an aborted transaction from callers\n const em = this.em.fork()\n const user = await em.findOne(User, { id: userId })\n if (!user) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n const tenantId = scope.tenantId || user.tenantId || null\n const orgId = scope.organizationId || user.organizationId || null\n\n if (!tenantId) {\n const result = { isSuperAdmin: false, features: [], organizations: null }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Per-user ACL first\n const uacl = await em.findOne(UserAcl, { user: userId as any, tenantId })\n if (uacl) {\n const result = {\n isSuperAdmin: !!uacl.isSuperAdmin,\n features: Array.isArray(uacl.featuresJson) ? (uacl.featuresJson as string[]) : [],\n organizations: Array.isArray(uacl.organizationsJson) ? (uacl.organizationsJson as string[]) : null,\n }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n // Aggregate role ACLs\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: orgId },\n )\n const linkList = Array.isArray(links) ? links : []\n const roleIds = linkList.map((l) => (l.role as any)?.id).filter(Boolean)\n let isSuper = false\n const features: string[] = []\n let organizations: string[] | null = []\n if (roleIds.length) {\n const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})\n const roleAcls = Array.isArray(racls) ? racls : []\n for (const r of roleAcls) {\n isSuper = isSuper || !!r.isSuperAdmin\n if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)\n if (organizations !== null) {\n if (r.organizationsJson == null) organizations = null\n else if (Array.isArray(r.organizationsJson) && r.organizationsJson.includes('__all__')) organizations = null\n else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))\n }\n }\n }\n if (organizations && orgId && !organizations.includes(orgId) && !organizations.includes('__all__')) {\n // Out-of-scope org; caller will enforce\n }\n const result = { isSuperAdmin: isSuper, features, organizations }\n await this.setCache(cacheKey, result, userId, scope)\n return result\n }\n\n /**\n * Returns the user's granted feature strings for a given scope.\n *\n * Used by infrastructure that needs the raw grant list rather than a yes/no\n * authorization check (for example response enrichers gating themselves with\n * `features: [...]`). Callers MUST apply wildcard-aware matching against the\n * returned array \u2014 grants like `module.*` or `*` are part of the ACL contract.\n *\n * @param userId - The ID of the user\n * @param scope - The tenant and organization context for ACL evaluation\n * @returns Array of feature strings (may include wildcards); empty array when\n * the user has no grants in scope\n */\n async getGrantedFeatures(\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<string[]> {\n const acl = await this.loadAcl(userId, scope)\n return Array.isArray(acl.features) ? acl.features : []\n }\n\n /**\n * Checks whether any tenant role grants a feature.\n *\n * This supports non-user runtimes such as scheduler workers that execute with\n * tenant scope but without an authenticated user.\n */\n async tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean> {\n if (!tenantId || !feature) return false\n\n const enabledIds = getEnabledModuleIds()\n if (enabledIds.length && !enabledIds.includes(getOwningModuleId(feature))) return false\n\n const em = this.em.fork()\n const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as any, {})\n const list = Array.isArray(roleAcls) ? roleAcls : []\n const organizationId = opts?.organizationId ?? null\n\n for (const acl of list) {\n if (!this.roleAclAllowsOrganization(acl, organizationId)) continue\n if (acl.isSuperAdmin) return true\n const grants = Array.isArray(acl.featuresJson) ? acl.featuresJson : []\n if (this.hasAllFeatures([feature], filterGrantsByEnabledModules(grants))) return true\n }\n\n return false\n }\n\n /**\n * Checks if a user has all required features within a given scope.\n *\n * This is the primary authorization check method used throughout the application.\n * It combines feature checking with organization visibility validation.\n *\n * Authorization logic:\n * 1. No features required \u2192 always returns true\n * 2. User is super admin \u2192 always returns true\n * 3. Organization restriction check: If the user's ACL has a restricted organization list\n * and the requested organization is not in that list \u2192 returns false\n * 4. Feature matching: User must have all required features (supports wildcards)\n *\n * @param userId - The ID of the user\n * @param required - Array of feature strings to check (e.g., ['users.view', 'users.edit'])\n * @param scope - The tenant and organization context for authorization\n * @returns true if the user has all required features and organization access, false otherwise\n *\n * @example\n * // Check if user can view and edit users\n * const canManageUsers = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['users.view', 'users.edit'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n *\n * @example\n * // Check with wildcard features\n * const canAccessEntities = await rbacService.userHasAllFeatures(\n * 'user-123',\n * ['entities.records.view'],\n * { tenantId: 'tenant-1', organizationId: 'org-1' }\n * )\n * // Returns true if user has 'entities.*', '*', or 'entities.records.view'\n */\n async userHasAllFeatures(userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean> {\n if (!required.length) return true\n const acl = await this.loadAcl(userId, scope)\n if (acl.isSuperAdmin) {\n const enabledIds = getEnabledModuleIds()\n if (!enabledIds.length) return true\n const enabledSet = new Set(enabledIds)\n return required.every((feature) => enabledSet.has(getOwningModuleId(feature)))\n }\n if (acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) return false\n return this.hasAllFeatures(required, filterGrantsByEnabledModules(acl.features))\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["result", "em", "tenantId", "roleIds", "isSuper", "features", "organizations"]
|
|
7
7
|
}
|