fix(security): tier-0 audit blockers (next CVE, role gate, perm traps, key validation, rate limits)
Closes the five highest-risk findings from docs/audit-comprehensive-2026-05-05.md so the platform is not exposed while the rest of the audit backlog (1 CRIT + 18 HIGH + 32 MED + 23 LOW) is worked through: * CVE-2025-29927 — bump next 15.1.0 → 15.2.9; nginx strips X-Middleware-Subrequest at the edge as defense-in-depth. * Cross-tenant role escalation — POST/PATCH/DELETE on /admin/roles now require super-admin (was: any holder of admin.manage_users). Adds shared `requireSuperAdmin(ctx)` helper. * Silent-403 traps — `documents.edit` and `files.edit` keys added to RolePermissions; seeded role values updated; migration 0041 backfills the new keys on every existing roles+port_role_overrides JSONB. File routes remap the dead `create` action to `upload` / `manage_folders`. * Berth-PDF / brochure register endpoints — reject body.storageKey unless it matches the namespace the matching presign endpoint issued (prevents repointing a tenant's PDF at foreign-port bytes). * Portal auth rate limits — sign-in 5/15min/(ip,email), forgot-password 3/hr/IP, activate/reset/set-password 10/hr/IP. Adds `enforcePublicRateLimit()` for non-`withAuth` routes. Test status unchanged: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md (CRITICAL, HIGH §§1–4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
|
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
|
||||||
|
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
|
||||||
|
# input at the edge means a future regression cannot reopen the bypass.
|
||||||
|
proxy_set_header X-Middleware-Subrequest "";
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"mailparser": "^3.9.4",
|
"mailparser": "^3.9.4",
|
||||||
"minio": "^8.0.0",
|
"minio": "^8.0.0",
|
||||||
"next": "15.1.0",
|
"next": "15.2.9",
|
||||||
"next-themes": "^0.4.0",
|
"next-themes": "^0.4.0",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
"drizzle-kit": "^0.30.0",
|
"drizzle-kit": "^0.30.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "15.1.0",
|
"eslint-config-next": "15.2.9",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^15.2.0",
|
"lint-staged": "^15.2.0",
|
||||||
|
|||||||
107
pnpm-lock.yaml
generated
107
pnpm-lock.yaml
generated
@@ -109,7 +109,7 @@ importers:
|
|||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
|
||||||
bullmq:
|
bullmq:
|
||||||
specifier: ^5.25.0
|
specifier: ^5.25.0
|
||||||
version: 5.71.0
|
version: 5.71.0
|
||||||
@@ -153,8 +153,8 @@ importers:
|
|||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.7
|
version: 8.0.7
|
||||||
next:
|
next:
|
||||||
specifier: 15.1.0
|
specifier: 15.2.9
|
||||||
version: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -271,8 +271,8 @@ importers:
|
|||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.39.4(jiti@1.21.7)
|
version: 9.39.4(jiti@1.21.7)
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 15.1.0
|
specifier: 15.2.9
|
||||||
version: 15.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
version: 15.2.9(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: ^9.1.0
|
specifier: ^9.1.0
|
||||||
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
|
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
|
||||||
@@ -1494,60 +1494,60 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@1.1.1':
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||||
|
|
||||||
'@next/env@15.1.0':
|
'@next/env@15.2.9':
|
||||||
resolution: {integrity: sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==}
|
resolution: {integrity: sha512-0JJ6OlIb1kZiAbY/Hi5XHb2ZT7B5/l8CyGX3GxtTY8LNl1Inm9EU8PnCtVzUR8N2Si3a1pX02PbKBlDcsHNvUQ==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@15.1.0':
|
'@next/eslint-plugin-next@15.2.9':
|
||||||
resolution: {integrity: sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg==}
|
resolution: {integrity: sha512-AgCS3+FYsSU4aHcmL+FutRWIJ52x9v/etDT+1ttWXEJILn3yo9ALp9lGgC6REtsj1/uPAsLFUh1uvs4LxW2KvQ==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.1.0':
|
'@next/swc-darwin-arm64@15.2.5':
|
||||||
resolution: {integrity: sha512-ZU8d7xxpX14uIaFC3nsr4L++5ZS/AkWDm1PzPO6gD9xWhFkOj2hzSbSIxoncsnlJXB1CbLOfGVN4Zk9tg83PUw==}
|
resolution: {integrity: sha512-4OimvVlFTbgzPdA0kh8A1ih6FN9pQkL4nPXGqemEYgk+e7eQhsst/p35siNNqA49eQA6bvKZ1ASsDtu9gtXuog==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.1.0':
|
'@next/swc-darwin-x64@15.2.5':
|
||||||
resolution: {integrity: sha512-DQ3RiUoW2XC9FcSM4ffpfndq1EsLV0fj0/UY33i7eklW5akPUCo6OX2qkcLXZ3jyPdo4sf2flwAED3AAq3Om2Q==}
|
resolution: {integrity: sha512-ohzRaE9YbGt1ctE0um+UGYIDkkOxHV44kEcHzLqQigoRLaiMtZzGrA11AJh2Lu0lv51XeiY1ZkUvkThjkVNBMA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.1.0':
|
'@next/swc-linux-arm64-gnu@15.2.5':
|
||||||
resolution: {integrity: sha512-M+vhTovRS2F//LMx9KtxbkWk627l5Q7AqXWWWrfIzNIaUFiz2/NkOFkxCFyNyGACi5YbA8aekzCLtbDyfF/v5Q==}
|
resolution: {integrity: sha512-FMSdxSUt5bVXqqOoZCc/Seg4LQep9w/fXTazr/EkpXW2Eu4IFI9FD7zBDlID8TJIybmvKk7mhd9s+2XWxz4flA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.1.0':
|
'@next/swc-linux-arm64-musl@15.2.5':
|
||||||
resolution: {integrity: sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==}
|
resolution: {integrity: sha512-4ZNKmuEiW5hRKkGp2HWwZ+JrvK4DQLgf8YDaqtZyn7NYdl0cHfatvlnLFSWUayx9yFAUagIgRGRk8pFxS8Qniw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.1.0':
|
'@next/swc-linux-x64-gnu@15.2.5':
|
||||||
resolution: {integrity: sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==}
|
resolution: {integrity: sha512-bE6lHQ9GXIf3gCDE53u2pTl99RPZW5V1GLHSRMJ5l/oB/MT+cohu9uwnCK7QUph2xIOu2a6+27kL0REa/kqwZw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.1.0':
|
'@next/swc-linux-x64-musl@15.2.5':
|
||||||
resolution: {integrity: sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==}
|
resolution: {integrity: sha512-y7EeQuSkQbTAkCEQnJXm1asRUuGSWAchGJ3c+Qtxh8LVjXleZast8Mn/rL7tZOm7o35QeIpIcid6ufG7EVTTcA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.1.0':
|
'@next/swc-win32-arm64-msvc@15.2.5':
|
||||||
resolution: {integrity: sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==}
|
resolution: {integrity: sha512-gQMz0yA8/dskZM2Xyiq2FRShxSrsJNha40Ob/M2n2+JGRrZ0JwTVjLdvtN6vCxuq4ByhOd4a9qEf60hApNR2gQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.1.0':
|
'@next/swc-win32-x64-msvc@15.2.5':
|
||||||
resolution: {integrity: sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==}
|
resolution: {integrity: sha512-tBDNVUcI7U03+3oMvJ11zrtVin5p0NctiuKmTGyaTIEAVj9Q77xukLXGXRnWxKRIIdFG4OTA2rUVGZDYOwgmAA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -3669,8 +3669,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
eslint-config-next@15.1.0:
|
eslint-config-next@15.2.9:
|
||||||
resolution: {integrity: sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw==}
|
resolution: {integrity: sha512-MWpGYzLdkJ38OF1g1R4wQe9GVvoinCyIeYofITHh5D3FmHuIOgeWAK46M+iUYrIG1cJNX0HPh5fHpjmuC3dnrw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
|
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
|
||||||
typescript: '>=3.3.1'
|
typescript: '>=3.3.1'
|
||||||
@@ -4677,10 +4677,9 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@15.1.0:
|
next@15.2.9:
|
||||||
resolution: {integrity: sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==}
|
resolution: {integrity: sha512-jXEBIPi+kIkMe5KI4okvGIWvot9hyiDz2fT4OqxxsSeZTA6zhSwrQkJwTE3GmQ1HQlolcQjTNMjHMvc8hhog7g==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
|
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.1.0
|
'@opentelemetry/api': ^1.1.0
|
||||||
@@ -7078,34 +7077,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@15.1.0': {}
|
'@next/env@15.2.9': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@15.1.0':
|
'@next/eslint-plugin-next@15.2.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.1.0':
|
'@next/swc-darwin-arm64@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.1.0':
|
'@next/swc-darwin-x64@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.1.0':
|
'@next/swc-linux-arm64-gnu@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.1.0':
|
'@next/swc-linux-arm64-musl@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.1.0':
|
'@next/swc-linux-x64-gnu@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.1.0':
|
'@next/swc-linux-x64-musl@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.1.0':
|
'@next/swc-win32-arm64-msvc@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.1.0':
|
'@next/swc-win32-x64-msvc@15.2.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@noble/ciphers@1.3.0': {}
|
'@noble/ciphers@1.3.0': {}
|
||||||
@@ -8591,7 +8590,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.8: {}
|
baseline-browser-mapping@2.10.8: {}
|
||||||
|
|
||||||
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
|
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
|
||||||
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))
|
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))
|
||||||
@@ -8614,7 +8613,7 @@ snapshots:
|
|||||||
drizzle-kit: 0.30.6
|
drizzle-kit: 0.30.6
|
||||||
drizzle-orm: 0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4)
|
drizzle-orm: 0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4)
|
||||||
mongodb: 7.1.0(socks@2.8.7)
|
mongodb: 7.1.0(socks@2.8.7)
|
||||||
next: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
next: 15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
@@ -9328,9 +9327,9 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-config-next@15.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
|
eslint-config-next@15.2.9(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 15.1.0
|
'@next/eslint-plugin-next': 15.2.9
|
||||||
'@rushstack/eslint-patch': 1.16.1
|
'@rushstack/eslint-patch': 1.16.1
|
||||||
'@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
'@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
|
||||||
@@ -10416,9 +10415,9 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.1.0
|
'@next/env': 15.2.9
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
@@ -10428,14 +10427,14 @@ snapshots:
|
|||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
styled-jsx: 5.1.6(react@19.2.4)
|
styled-jsx: 5.1.6(react@19.2.4)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 15.1.0
|
'@next/swc-darwin-arm64': 15.2.5
|
||||||
'@next/swc-darwin-x64': 15.1.0
|
'@next/swc-darwin-x64': 15.2.5
|
||||||
'@next/swc-linux-arm64-gnu': 15.1.0
|
'@next/swc-linux-arm64-gnu': 15.2.5
|
||||||
'@next/swc-linux-arm64-musl': 15.1.0
|
'@next/swc-linux-arm64-musl': 15.2.5
|
||||||
'@next/swc-linux-x64-gnu': 15.1.0
|
'@next/swc-linux-x64-gnu': 15.2.5
|
||||||
'@next/swc-linux-x64-musl': 15.1.0
|
'@next/swc-linux-x64-musl': 15.2.5
|
||||||
'@next/swc-win32-arm64-msvc': 15.1.0
|
'@next/swc-win32-arm64-msvc': 15.2.5
|
||||||
'@next/swc-win32-x64-msvc': 15.1.0
|
'@next/swc-win32-x64-msvc': 15.2.5
|
||||||
'@playwright/test': 1.58.2
|
'@playwright/test': 1.58.2
|
||||||
sharp: 0.33.5
|
sharp: 0.33.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
|
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -10,6 +11,10 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// 10/hour/IP — bounds brute-force against the CRM invite token.
|
||||||
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -10,6 +11,10 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
|
||||||
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
const bodySchema = z.object({ email: z.string().email() });
|
const bodySchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// 3/hour/IP — tightest of the portal limiters because each successful
|
||||||
|
// call sends an outbound email and timing differences here are the
|
||||||
|
// primary email-enumeration vector.
|
||||||
|
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -10,6 +11,10 @@ const bodySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
|
||||||
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||||
import { signIn } from '@/lib/services/portal-auth.service';
|
import { signIn } from '@/lib/services/portal-auth.service';
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -17,14 +18,24 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
|
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||||
|
// the limiter is per-account-per-IP, not just per-IP — a NATed network
|
||||||
|
// shouldn't be able to lock a single victim by burning their bucket.
|
||||||
|
const limited = await enforcePublicRateLimit(
|
||||||
|
req,
|
||||||
|
'portalSignIn',
|
||||||
|
parsed.data.email.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signIn(parsed.data);
|
const result = await signIn(parsed.data);
|
||||||
const res = NextResponse.json({ success: true });
|
const res = NextResponse.json({ success: true });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
generateBrochureStorageKey,
|
generateBrochureStorageKey,
|
||||||
registerBrochureVersion,
|
registerBrochureVersion,
|
||||||
@@ -46,11 +46,28 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Storage keys generated by `generateBrochureStorageKey` look like
|
||||||
|
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else —
|
||||||
|
// without this, an admin holding manage_settings on port A could ship a
|
||||||
|
// foreign port's storage key (signed EOI bytes, another port's brochure)
|
||||||
|
// and have registerBrochureVersion repoint THIS port's brochure version
|
||||||
|
// at confidential bytes that subsequently serve under brochures.view.
|
||||||
|
const BROCHURE_KEY_RE =
|
||||||
|
/^[a-z0-9-]+\/brochures\/[A-Za-z0-9_-]+\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$/;
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const id = params.id!;
|
const id = params.id!;
|
||||||
const input = await parseBody(req, registerBrochureVersionSchema);
|
const input = await parseBody(req, registerBrochureVersionSchema);
|
||||||
|
if (!BROCHURE_KEY_RE.test(input.storageKey)) {
|
||||||
|
throw new ValidationError('storageKey is not in the expected brochure path');
|
||||||
|
}
|
||||||
|
const segments = input.storageKey.split('/');
|
||||||
|
// segments: [portSlug, 'brochures', brochureId, '<uuid>.pdf']
|
||||||
|
if (segments[2] !== id) {
|
||||||
|
throw new ValidationError('storageKey brochureId does not match route param');
|
||||||
|
}
|
||||||
const data = await registerBrochureVersion({
|
const data = await registerBrochureVersion({
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
brochureId: id,
|
brochureId: id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
|
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
|
||||||
import { updateRoleSchema } from '@/lib/validators/roles';
|
import { updateRoleSchema } from '@/lib/validators/roles';
|
||||||
@@ -17,35 +17,34 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PATCH = withAuth(
|
// Mutations on global roles are super-admin-only — see route.ts header.
|
||||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req, updateRoleSchema);
|
requireSuperAdmin(ctx, 'roles.update');
|
||||||
const data = await updateRole(params.id!, body, {
|
const body = await parseBody(req, updateRoleSchema);
|
||||||
userId: ctx.userId,
|
const data = await updateRole(params.id!, body, {
|
||||||
portId: ctx.portId,
|
userId: ctx.userId,
|
||||||
ipAddress: ctx.ipAddress,
|
portId: ctx.portId,
|
||||||
userAgent: ctx.userAgent,
|
ipAddress: ctx.ipAddress,
|
||||||
});
|
userAgent: ctx.userAgent,
|
||||||
return NextResponse.json({ data });
|
});
|
||||||
} catch (error) {
|
return NextResponse.json({ data });
|
||||||
return errorResponse(error);
|
} catch (error) {
|
||||||
}
|
return errorResponse(error);
|
||||||
}),
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
export const DELETE = withAuth(
|
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
try {
|
||||||
try {
|
requireSuperAdmin(ctx, 'roles.delete');
|
||||||
await deleteRole(params.id!, {
|
await deleteRole(params.id!, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { listRoles, createRole } from '@/lib/services/roles.service';
|
import { listRoles, createRole } from '@/lib/services/roles.service';
|
||||||
import { createRoleSchema } from '@/lib/validators/roles';
|
import { createRoleSchema } from '@/lib/validators/roles';
|
||||||
@@ -17,19 +17,22 @@ export const GET = withAuth(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const POST = withAuth(
|
// Roles are global (no port_id) and assignments span every port via
|
||||||
withPermission('admin', 'manage_users', async (req, ctx) => {
|
// userPortRoles, so creation must be super-admin-only — a per-port admin
|
||||||
try {
|
// holding admin.manage_users must never be able to mint a role that lives
|
||||||
const body = await parseBody(req, createRoleSchema);
|
// in another tenant.
|
||||||
const data = await createRole(body, {
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
userId: ctx.userId,
|
try {
|
||||||
portId: ctx.portId,
|
requireSuperAdmin(ctx, 'roles.create');
|
||||||
ipAddress: ctx.ipAddress,
|
const body = await parseBody(req, createRoleSchema);
|
||||||
userAgent: ctx.userAgent,
|
const data = await createRole(body, {
|
||||||
});
|
userId: ctx.userId,
|
||||||
return NextResponse.json({ data }, { status: 201 });
|
portId: ctx.portId,
|
||||||
} catch (error) {
|
ipAddress: ctx.ipAddress,
|
||||||
return errorResponse(error);
|
userAgent: ctx.userAgent,
|
||||||
}
|
});
|
||||||
}),
|
return NextResponse.json({ data }, { status: 201 });
|
||||||
);
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Defense against the post-upload register endpoint trusting an arbitrary
|
||||||
|
// storageKey from the body. The companion presign endpoint always issues
|
||||||
|
// `berths/<berthId>/uploads/<uuid>_<sanitized>` (see ./pdf-upload-url),
|
||||||
|
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
|
||||||
|
// a rep with berths.edit could ship the storage key of a foreign-port
|
||||||
|
// PDF (signed EOI, brochure blob, another port's berth) and have the
|
||||||
|
// service repoint THIS berth's currentPdfVersionId at it — subsequent
|
||||||
|
// pdf-download serves those bytes under the rep's own permission gate.
|
||||||
|
const STORAGE_KEY_RE =
|
||||||
|
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
|
||||||
|
|
||||||
export const postHandler: RouteHandler = async (req, ctx, params) => {
|
export const postHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Partial<PostBody>;
|
const body = (await req.json()) as Partial<PostBody>;
|
||||||
@@ -46,6 +57,12 @@ export const postHandler: RouteHandler = async (req, ctx, params) => {
|
|||||||
if (!body.sha256 || typeof body.sha256 !== 'string') {
|
if (!body.sha256 || typeof body.sha256 !== 'string') {
|
||||||
throw new ValidationError('sha256 is required');
|
throw new ValidationError('sha256 is required');
|
||||||
}
|
}
|
||||||
|
const expectedPrefix = `berths/${params.id!}/uploads/`;
|
||||||
|
if (!body.storageKey.startsWith(expectedPrefix) || !STORAGE_KEY_RE.test(body.storageKey)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'storageKey must come from the matching presign endpoint for this berth',
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await uploadBerthPdf({
|
const result = await uploadBerthPdf({
|
||||||
berthId: params.id!,
|
berthId: params.id!,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function sanitizeFolderPath(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('files', 'edit', async (req, ctx, params) => {
|
withPermission('files', 'manage_folders', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const pathSegments = params.path;
|
const pathSegments = params.path;
|
||||||
const currentPath = Array.isArray(pathSegments)
|
const currentPath = Array.isArray(pathSegments)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const createFolderSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('files', 'create', async (req, ctx) => {
|
withPermission('files', 'manage_folders', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req, createFolderSchema);
|
const body = await parseBody(req, createFolderSchema);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { uploadFile } from '@/lib/services/files';
|
|||||||
import { uploadFileSchema } from '@/lib/validators/files';
|
import { uploadFileSchema } from '@/lib/validators/files';
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('files', 'create', async (req, ctx) => {
|
withPermission('files', 'upload', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { db } from '@/lib/db';
|
|||||||
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
|
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
|
||||||
import { type RolePermissions } from '@/lib/db/schema/users';
|
import { type RolePermissions } from '@/lib/db/schema/users';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { runWithRequestContext, getRequestContext } from '@/lib/request-context';
|
import { runWithRequestContext, getRequestContext } from '@/lib/request-context';
|
||||||
import {
|
import {
|
||||||
@@ -250,6 +250,31 @@ export function withAuth(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── requireSuperAdmin ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws ForbiddenError when the caller is not a super-admin. Use inside
|
||||||
|
* route handlers (after withAuth) for endpoints that mutate global, cross-
|
||||||
|
* tenant state — global roles, cross-port migrations, system jobs.
|
||||||
|
*
|
||||||
|
* Logs the denied attempt to the audit trail (mirrors withPermission).
|
||||||
|
*/
|
||||||
|
export function requireSuperAdmin(ctx: AuthContext, attemptedAction = 'super_admin_only'): void {
|
||||||
|
if (ctx.isSuperAdmin) return;
|
||||||
|
logger.warn({ userId: ctx.userId, attemptedAction }, 'Super-admin gate denied');
|
||||||
|
void createAuditLog({
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
action: 'permission_denied',
|
||||||
|
entityType: 'super_admin',
|
||||||
|
entityId: '',
|
||||||
|
metadata: { attemptedAction },
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
throw new ForbiddenError('Super admin access required');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── withPermission ──────────────────────────────────────────────────────────
|
// ─── withPermission ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z, ZodSchema } from 'zod';
|
import { z, ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimiters,
|
||||||
|
rateLimitHeaders,
|
||||||
|
type RateLimiterName,
|
||||||
|
} from '@/lib/rate-limit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base list query schema shared by all paginated list endpoints.
|
* Base list query schema shared by all paginated list endpoints.
|
||||||
*/
|
*/
|
||||||
@@ -22,10 +29,7 @@ export type BaseListQuery = z.infer<typeof baseListQuerySchema>;
|
|||||||
* Parses URL search params against a Zod schema.
|
* Parses URL search params against a Zod schema.
|
||||||
* Throws a ZodError on validation failure (caught by `errorResponse`).
|
* Throws a ZodError on validation failure (caught by `errorResponse`).
|
||||||
*/
|
*/
|
||||||
export function parseQuery<T extends ZodSchema>(
|
export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.infer<T> {
|
||||||
req: NextRequest,
|
|
||||||
schema: T,
|
|
||||||
): z.infer<T> {
|
|
||||||
const params = Object.fromEntries(req.nextUrl.searchParams.entries());
|
const params = Object.fromEntries(req.nextUrl.searchParams.entries());
|
||||||
return schema.parse(params);
|
return schema.parse(params);
|
||||||
}
|
}
|
||||||
@@ -41,3 +45,52 @@ export async function parseBody<T extends ZodSchema>(
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
return schema.parse(body);
|
return schema.parse(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client IP from forwarded headers. The trusted proxy is
|
||||||
|
* nginx (which sets `x-forwarded-for` from `$proxy_add_x_forwarded_for`),
|
||||||
|
* so the leftmost token is the original client. Falls back to a literal
|
||||||
|
* `unknown` so the per-IP key still exists when running outside the
|
||||||
|
* proxy (dev, tests).
|
||||||
|
*/
|
||||||
|
export function clientIp(req: NextRequest): string {
|
||||||
|
const xff = req.headers.get('x-forwarded-for');
|
||||||
|
if (xff) {
|
||||||
|
const first = xff.split(',')[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
return req.headers.get('x-real-ip') ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an unauthenticated route handler with a per-IP (or per-key) rate
|
||||||
|
* limit. Used for portal/auth endpoints that have no session yet — the
|
||||||
|
* `withRateLimit` helper in api/helpers.ts is keyed on `ctx.userId` and
|
||||||
|
* cannot apply here.
|
||||||
|
*
|
||||||
|
* If `keySuffix` is provided, it's appended to the IP so a single client
|
||||||
|
* IP can't exhaust an unrelated user's bucket (e.g. for sign-in we key
|
||||||
|
* on `${ip}:${email}` so per-account brute force is the bottleneck and
|
||||||
|
* a noisy NAT IP doesn't deny everyone).
|
||||||
|
*/
|
||||||
|
export async function enforcePublicRateLimit(
|
||||||
|
req: NextRequest,
|
||||||
|
name: RateLimiterName,
|
||||||
|
keySuffix?: string,
|
||||||
|
): Promise<NextResponse | null> {
|
||||||
|
const config = rateLimiters[name];
|
||||||
|
const identifier = keySuffix ? `${clientIp(req)}:${keySuffix}` : clientIp(req);
|
||||||
|
const result = await checkRateLimit(identifier, config);
|
||||||
|
if (result.allowed) return null;
|
||||||
|
const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many requests. Please try again shortly.', retryAfter: retryAfterSec },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
...rateLimitHeaders(result),
|
||||||
|
'Retry-After': retryAfterSec.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
58
src/lib/db/migrations/0041_role_permissions_edit_keys.sql
Normal file
58
src/lib/db/migrations/0041_role_permissions_edit_keys.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- Backfill the new `documents.edit` and `files.edit` permission keys on
|
||||||
|
-- every existing row in `roles.permissions`. The schema (RolePermissions
|
||||||
|
-- in src/lib/db/schema/users.ts) added these keys to close the silent-403
|
||||||
|
-- traps on PATCH /api/v1/documents/[id], /cancel, /remind, /watchers, and
|
||||||
|
-- PATCH /api/v1/files/[id] — each used a permission key that did not exist
|
||||||
|
-- in the schema, so withPermission()'s `resourcePerms[action]` returned
|
||||||
|
-- undefined and 403'd every non-superadmin call.
|
||||||
|
--
|
||||||
|
-- Backfill rule:
|
||||||
|
-- documents.edit ← documents.create (anyone who can create can edit)
|
||||||
|
-- files.edit ← files.upload (same rationale)
|
||||||
|
--
|
||||||
|
-- jsonb_set with create_missing=true (the default) inserts the key only
|
||||||
|
-- when it's absent, so re-runs are idempotent and the migration is safe
|
||||||
|
-- against a partial run.
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = jsonb_set(
|
||||||
|
permissions,
|
||||||
|
'{documents,edit}',
|
||||||
|
COALESCE(permissions->'documents'->'create', 'false'::jsonb),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE permissions->'documents' IS NOT NULL
|
||||||
|
AND NOT (permissions->'documents' ? 'edit');
|
||||||
|
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = jsonb_set(
|
||||||
|
permissions,
|
||||||
|
'{files,edit}',
|
||||||
|
COALESCE(permissions->'files'->'upload', 'false'::jsonb),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE permissions->'files' IS NOT NULL
|
||||||
|
AND NOT (permissions->'files' ? 'edit');
|
||||||
|
|
||||||
|
-- Same backfill on per-port overrides (`port_role_overrides.permissions`)
|
||||||
|
-- so an override that flipped a sibling permission stays consistent.
|
||||||
|
|
||||||
|
UPDATE port_role_overrides
|
||||||
|
SET permissions = jsonb_set(
|
||||||
|
permissions,
|
||||||
|
'{documents,edit}',
|
||||||
|
COALESCE(permissions->'documents'->'create', 'false'::jsonb),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE permissions->'documents' IS NOT NULL
|
||||||
|
AND NOT (permissions->'documents' ? 'edit');
|
||||||
|
|
||||||
|
UPDATE port_role_overrides
|
||||||
|
SET permissions = jsonb_set(
|
||||||
|
permissions,
|
||||||
|
'{files,edit}',
|
||||||
|
COALESCE(permissions->'files'->'upload', 'false'::jsonb),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE permissions->'files' IS NOT NULL
|
||||||
|
AND NOT (permissions->'files' ? 'edit');
|
||||||
@@ -288,6 +288,13 @@
|
|||||||
"when": 1778300000000,
|
"when": 1778300000000,
|
||||||
"tag": "0040_error_events",
|
"tag": "0040_error_events",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778400000000,
|
||||||
|
"tag": "0041_role_permissions_edit_keys",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: boolean;
|
view: boolean;
|
||||||
create: boolean;
|
create: boolean;
|
||||||
|
edit: boolean;
|
||||||
send_for_signing: boolean;
|
send_for_signing: boolean;
|
||||||
upload_signed: boolean;
|
upload_signed: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
@@ -54,6 +55,7 @@ export type RolePermissions = {
|
|||||||
files: {
|
files: {
|
||||||
view: boolean;
|
view: boolean;
|
||||||
upload: boolean;
|
upload: boolean;
|
||||||
|
edit: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
manage_folders: boolean;
|
manage_folders: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -63,7 +64,7 @@ const ALL_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -116,6 +117,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -137,7 +139,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -190,6 +192,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -211,7 +214,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: false, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: false, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -264,6 +267,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -285,7 +289,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: false, manage_folders: false },
|
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -338,6 +342,7 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: false,
|
create: false,
|
||||||
|
edit: false,
|
||||||
send_for_signing: false,
|
send_for_signing: false,
|
||||||
upload_signed: false,
|
upload_signed: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -359,7 +364,7 @@ const VIEWER_PERMISSIONS: RolePermissions = {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: false, delete: false, manage_folders: false },
|
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: false },
|
email: { view: true, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ export const rateLimiters = {
|
|||||||
* without dropping data. The shared-secret header gates abuse; this
|
* without dropping data. The shared-secret header gates abuse; this
|
||||||
* limiter is just a defensive backstop in case the secret leaks. */
|
* limiter is just a defensive backstop in case the secret leaks. */
|
||||||
websiteIntake: { windowMs: 60 * 60 * 1000, max: 500, keyPrefix: 'websiteintake' },
|
websiteIntake: { windowMs: 60 * 60 * 1000, max: 500, keyPrefix: 'websiteintake' },
|
||||||
|
/** Portal sign-in: 5 attempts per 15min per (ip,email) bucket. Defends
|
||||||
|
* against credential stuffing on /api/portal/auth/sign-in. */
|
||||||
|
portalSignIn: { windowMs: 15 * 60 * 1000, max: 5, keyPrefix: 'portal:signin' },
|
||||||
|
/** Portal forgot-password: 3/hour/IP. Tighter than sign-in because it
|
||||||
|
* triggers an outbound email and is the primary email-enumeration
|
||||||
|
* vector (timing differences between known/unknown). */
|
||||||
|
portalForgot: { windowMs: 60 * 60 * 1000, max: 3, keyPrefix: 'portal:forgot' },
|
||||||
|
/** Portal activate / reset / set-password: 10/hour/IP. Bounds brute-
|
||||||
|
* force against the 32-byte token (random walk math is in our favour
|
||||||
|
* but a tight ceiling keeps the search space practically infeasible). */
|
||||||
|
portalToken: { windowMs: 60 * 60 * 1000, max: 10, keyPrefix: 'portal:token' },
|
||||||
} as const satisfies Record<string, RateLimitConfig>;
|
} as const satisfies Record<string, RateLimitConfig>;
|
||||||
|
|
||||||
export type RateLimiterName = keyof typeof rateLimiters;
|
export type RateLimiterName = keyof typeof rateLimiters;
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export function makeFullPermissions(): RolePermissions {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -337,7 +338,7 @@ export function makeFullPermissions(): RolePermissions {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -393,6 +394,7 @@ export function makeViewerPermissions(): RolePermissions {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: false,
|
create: false,
|
||||||
|
edit: false,
|
||||||
send_for_signing: false,
|
send_for_signing: false,
|
||||||
upload_signed: false,
|
upload_signed: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -414,7 +416,7 @@ export function makeViewerPermissions(): RolePermissions {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: false, delete: false, manage_folders: false },
|
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: false },
|
email: { view: true, send: false, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -470,6 +472,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: false,
|
delete: false,
|
||||||
@@ -491,7 +494,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
|||||||
record_payment: false,
|
record_payment: false,
|
||||||
export: false,
|
export: false,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: false, manage_folders: false },
|
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: false },
|
email: { view: true, send: true, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
@@ -547,6 +550,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
|||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
edit: true,
|
||||||
send_for_signing: true,
|
send_for_signing: true,
|
||||||
upload_signed: true,
|
upload_signed: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
@@ -568,7 +572,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
|||||||
record_payment: true,
|
record_payment: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: false },
|
email: { view: true, send: true, configure_account: false },
|
||||||
reminders: {
|
reminders: {
|
||||||
view_own: true,
|
view_own: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user