From 745f0e4940364dabc61d1f28cd5208f8134105ba Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 09:38:16 +0200 Subject: [PATCH 01/34] feat: add Modgud product landing page --- .gitignore | 1 + package.json | 2 + src/landing-page/astro.config.mjs | 5 + src/landing-page/package.json | 27 + src/landing-page/pnpm-lock.yaml | 3335 +++++++++++++++++ src/landing-page/public/shield.svg | 26 + src/landing-page/public/social-preview.png | Bin 0 -> 86899 bytes .../src/components/RevealScript.astro | 29 + src/landing-page/src/layouts/BaseLayout.astro | 48 + src/landing-page/src/pages/index.astro | 506 +++ src/landing-page/src/styles/global.css | 2099 +++++++++++ src/landing-page/tsconfig.json | 3 + 12 files changed, 6081 insertions(+) create mode 100644 src/landing-page/astro.config.mjs create mode 100644 src/landing-page/package.json create mode 100644 src/landing-page/pnpm-lock.yaml create mode 100644 src/landing-page/public/shield.svg create mode 100644 src/landing-page/public/social-preview.png create mode 100644 src/landing-page/src/components/RevealScript.astro create mode 100644 src/landing-page/src/layouts/BaseLayout.astro create mode 100644 src/landing-page/src/pages/index.astro create mode 100644 src/landing-page/src/styles/global.css create mode 100644 src/landing-page/tsconfig.json diff --git a/.gitignore b/.gitignore index 0436a4ca..b1c720a6 100644 --- a/.gitignore +++ b/.gitignore @@ -288,6 +288,7 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ +.astro/ # Visual Studio 6 build log *.plg diff --git a/package.json b/package.json index 6de17f56..ef9fef23 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "license": "Apache-2.0", "description": "Root convenience scripts for building the Vue SPA and the in-app docs straight into the backend's wwwroot — used when you want `dotnet run` (no Docker) to serve a current frontend + docs.", "scripts": { + "dev:landing": "cd src/landing-page && pnpm dev", + "build:landing": "cd src/landing-page && pnpm install --prefer-offline && pnpm build", "build:frontend": "cd src/frontend-vue && pnpm install --prefer-offline && pnpm exec vite build --outDir ../dotnet/Modgud.Api/wwwroot --emptyOutDir", "build:docs": "cd docs && pnpm install --prefer-offline && pnpm exec vitepress build --config .vitepress/config.in-app.ts --outDir ../src/dotnet/Modgud.Api/wwwroot/docs", "build:backend": "pnpm run build:frontend && pnpm run build:docs", diff --git a/src/landing-page/astro.config.mjs b/src/landing-page/astro.config.mjs new file mode 100644 index 00000000..0654f132 --- /dev/null +++ b/src/landing-page/astro.config.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: 'static', +}); diff --git a/src/landing-page/package.json b/src/landing-page/package.json new file mode 100644 index 00000000..fdef5a32 --- /dev/null +++ b/src/landing-page/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cocoar/modgud-landing-page", + "private": true, + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "check": "astro check" + }, + "dependencies": { + "@fontsource-variable/bricolage-grotesque": "^5.2.8", + "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-mono": "^5.2.8", + "astro": "^7.1.3" + }, + "devDependencies": { + "@astrojs/check": "^0.9.0", + "typescript": "^5.9.3" + }, + "volta": { + "node": "22.17.1", + "pnpm": "10.13.1" + } +} diff --git a/src/landing-page/pnpm-lock.yaml b/src/landing-page/pnpm-lock.yaml new file mode 100644 index 00000000..3ac882af --- /dev/null +++ b/src/landing-page/pnpm-lock.yaml @@ -0,0 +1,3335 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/bricolage-grotesque': + specifier: ^5.2.8 + version: 5.3.0 + '@fontsource-variable/ibm-plex-sans': + specifier: ^5.2.8 + version: 5.3.0 + '@fontsource/ibm-plex-mono': + specifier: ^5.2.8 + version: 5.3.0 + astro: + specifier: ^7.1.3 + version: 7.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(yaml@2.9.0) + devDependencies: + '@astrojs/check': + specifier: ^0.9.0 + version: 0.9.9(prettier@3.9.6)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@astrojs/check@0.9.9': + resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler-binding-darwin-arm64@0.3.1': + resolution: {integrity: sha512-IEmEF2fUIlTHtpeE/isyEGVOB14cEyh/LZOFYt6wn3jNyVpdC8aR5OZ+RzFUR/f+8ZDM1LaMwZKvoA7eMyJeFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.3.1': + resolution: {integrity: sha512-GF2kIxjpPDLsn94zbZNMsxEmkU828QqnmM7kiQJnaooS3jmI+I7kk6+oI6EpwOsK3femCMdcm+wmOsEqtGrmjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.3.1': + resolution: {integrity: sha512-XJL3SDmOtVrqFhCirNcHwE91+IesJqlgNo23I4qW9QUYfwzm/TBZuH61fgqsb1ttgR1mMYz6ooPWs0JDhwMqpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@astrojs/compiler-binding-linux-arm64-musl@0.3.1': + resolution: {integrity: sha512-xqE8BVbDoBueK/B47w30PtkVofUWJKGkwoMVE+EOMLf11rnoANxIAdA9FPqY+rng4oNI5ndHGsri1yPj2k8vZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@astrojs/compiler-binding-linux-x64-gnu@0.3.1': + resolution: {integrity: sha512-1y0StU1qiCuDFH3rmbRJXcxdfHxFPrES1Rd+RLffosvUR7I2cH5SF5SFnBN9vXpzpkmyElZm3Yr47iJBPN7vVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@astrojs/compiler-binding-linux-x64-musl@0.3.1': + resolution: {integrity: sha512-16q0fYf7kpbmdObZEeZJEup8hQv/whgNwVjrSvT8umrKwLDSnNIWiQpm09lQQu6bweZB0XyIvHwlPitvJhC+hg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@astrojs/compiler-binding-wasm32-wasi@0.3.1': + resolution: {integrity: sha512-cB456shIwDv/PrVT+2QG7LFndpHkVge5HjqADKZgGaAc9JHVktCtjSrcdkRQ+3tbkPazNKaTLRjXLIiz2NIx9g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.3.1': + resolution: {integrity: sha512-ur/9+If/yTE69mmeX5MqSZndL0HOyx67GeNZUy3N7wVdWpLz9UTJXwyWS4UR2PUQHitghjsM5xoX0Ge56WRVQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.3.1': + resolution: {integrity: sha512-k0W+kDBzDkNZOqu4kElDvCOIbKw5Ut9S1WZ1Krj3KTgNuBERNKXsMMsRLLcbgfdMdbe7bTekQLshZrrvmYpmwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.3.1': + resolution: {integrity: sha512-DaAUj29AIBU2XdJ8uwcab8lW5O2pk9pY8AXkcMw0sw77nVa3oeTYRcO+Dvbbpoexf6ThMc0FMWYCQ/wN1/T7oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.3.1': + resolution: {integrity: sha512-aT7xkgsbNoS6nriY5qKpbihK43slFHO41iqgHCTdOvn1ifaQxLCc5yXy+6GzAtiafoaC1zA7OwVXCXMsvUZOkg==} + engines: {node: '>=22.12.0'} + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/internal-helpers@0.10.1': + resolution: {integrity: sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==} + + '@astrojs/language-server@2.16.13': + resolution: {integrity: sha512-ekOa+CYprEq5n4EJC1qTIAhLk49HZIUQuFwrEuF+3JK/pdMaYnWoREFUI2A0KEPOJiFA2kamBzKzbYljDvUxLg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.3.4': + resolution: {integrity: sha512-6Lvt/bQZEBW+zzdhPblvfZEy5PGEYJaUsUqaCgwHeRPxZJL1gc9I+DRLKWJjjYTWDzVUTzXlMq4WwSK+X34CVw==} + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.9.5': + resolution: {integrity: sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.9.5': + resolution: {integrity: sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA==} + cpu: [arm64] + os: [linux] + + '@bruits/satteri-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ==} + cpu: [arm64] + os: [linux] + + '@bruits/satteri-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw==} + cpu: [x64] + os: [linux] + + '@bruits/satteri-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q==} + cpu: [x64] + os: [linux] + + '@bruits/satteri-wasm32-wasi@0.9.5': + resolution: {integrity: sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.9.5': + resolution: {integrity: sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.9.5': + resolution: {integrity: sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fontsource-variable/bricolage-grotesque@5.3.0': + resolution: {integrity: sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==} + + '@fontsource-variable/ibm-plex-sans@5.3.0': + resolution: {integrity: sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==} + + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.3.1': + resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.3.1': + resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.3.1': + resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + + '@shikijs/themes@4.3.1': + resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} + engines: {node: '>=20'} + + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + astro@7.1.3: + resolution: {integrity: sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': 7.2.1 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.2: + resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.1: + resolution: {integrity: sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==} + engines: {node: '>=20'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + satteri@0.9.5: + resolution: {integrity: sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.3.1: + resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + svgo@4.0.2: + resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/check@0.9.9(prettier@3.9.6)(typescript@5.9.3)': + dependencies: + '@astrojs/language-server': 2.16.13(prettier@3.9.6)(typescript@5.9.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 5.9.3 + yargs: 17.7.3 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-darwin-arm64@0.3.1': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.3.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.3.1': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.3.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.3.1': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.3.1': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.3.1': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.3.1': + optional: true + + '@astrojs/compiler-binding@0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + optionalDependencies: + '@astrojs/compiler-binding-darwin-arm64': 0.3.1 + '@astrojs/compiler-binding-darwin-x64': 0.3.1 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.3.1 + '@astrojs/compiler-binding-linux-arm64-musl': 0.3.1 + '@astrojs/compiler-binding-linux-x64-gnu': 0.3.1 + '@astrojs/compiler-binding-linux-x64-musl': 0.3.1 + '@astrojs/compiler-binding-wasm32-wasi': 0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.3.1 + '@astrojs/compiler-binding-win32-x64-msvc': 0.3.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler-rs@0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@astrojs/compiler-binding': 0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.10.1': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.0 + picomatch: 4.0.5 + retext-smartypants: 6.2.0 + shiki: 4.3.1 + smol-toml: 1.7.0 + unified: 11.0.5 + + '@astrojs/language-server@2.16.13(prettier@3.9.6)(typescript@5.9.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28)(prettier@3.9.6) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.9.6 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-satteri@0.3.4': + dependencies: + '@astrojs/internal-helpers': 0.10.1 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + satteri: 0.9.5 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.8.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.9.5': + optional: true + + '@bruits/satteri-darwin-x64@0.9.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.9.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.9.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.9.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.9.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.9.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.9.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.9.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@fontsource-variable/bricolage-grotesque@5.3.0': {} + + '@fontsource-variable/ibm-plex-sans@5.3.0': {} + + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.3': {} + + '@volar/kit@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 5.9.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + astro@7.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(yaml@2.9.0): + dependencies: + '@astrojs/compiler-rs': 0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@astrojs/internal-helpers': 0.10.1 + '@astrojs/markdown-satteri': 0.3.4 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.7.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.4.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.8.2 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.3.1 + esbuild: 0.28.1 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 2.1.4 + p-limit: 7.3.1 + p-queue: 9.3.3 + package-manager-detector: 1.8.0 + piccolore: 0.1.3 + picomatch: 4.0.5 + semver: 7.8.5 + shiki: 4.3.1 + smol-toml: 1.7.0 + svgo: 4.0.2 + tinyclip: 0.1.15 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.4 + unstorage: 1.17.5 + vite: 8.1.5(esbuild@0.28.1)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.1.5(esbuild@0.28.1)(yaml@2.9.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.35.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@emnapi/core' + - '@emnapi/runtime' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@2.0.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.8.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + eventemitter3@5.0.4: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.4: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + iron-webcrypto@1.2.1: {} + + is-docker@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-plain-obj@4.1.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + mrmime@2.0.1: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.16: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.4: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.1: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.8.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + radix3@1.1.2: {} + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + satteri@0.9.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.9.5 + '@bruits/satteri-darwin-x64': 0.9.5 + '@bruits/satteri-linux-arm64-gnu': 0.9.5 + '@bruits/satteri-linux-arm64-musl': 0.9.5 + '@bruits/satteri-linux-x64-gnu': 0.9.5 + '@bruits/satteri-linux-x64-musl': 0.9.5 + '@bruits/satteri-wasm32-wasi': 0.9.5 + '@bruits/satteri-win32-arm64-msvc': 0.9.5 + '@bruits/satteri-win32-x64-msvc': 0.9.5 + + sax@1.6.0: {} + + semver@7.8.5: {} + + sharp@0.35.3: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + optional: true + + shiki@4.3.1: + dependencies: + '@shikijs/core': 4.3.1 + '@shikijs/engine-javascript': 4.3.1 + '@shikijs/engine-oniguruma': 4.3.1 + '@shikijs/langs': 4.3.1 + '@shikijs/themes': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + sisteransi@1.0.5: {} + + smol-toml@1.7.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + svgo@4.0.2: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.15: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.1.5(esbuild@0.28.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.22 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + esbuild: 0.28.1 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitefu@1.1.3(vite@8.1.5(esbuild@0.28.1)(yaml@2.9.0)): + optionalDependencies: + vite: 8.1.5(esbuild@0.28.1)(yaml@2.9.0) + + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28)(prettier@3.9.6): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.9.6 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + web-namespaces@2.0.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.6 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/src/landing-page/public/shield.svg b/src/landing-page/public/shield.svg new file mode 100644 index 00000000..6f253d23 --- /dev/null +++ b/src/landing-page/public/shield.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/landing-page/public/social-preview.png b/src/landing-page/public/social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..f37d38506e3396a3e3b1f20636a43f05022f5ebf GIT binary patch literal 86899 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 + const reveal = () => { + const elements = document.querySelectorAll('[data-reveal]'); + + if (!('IntersectionObserver' in window)) { + elements.forEach((element) => element.classList.add('is-visible')); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + }); + }, + { rootMargin: '0px 0px -8% 0px', threshold: 0.08 }, + ); + + elements.forEach((element) => observer.observe(element)); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', reveal, { once: true }); + } else { + reveal(); + } + diff --git a/src/landing-page/src/layouts/BaseLayout.astro b/src/landing-page/src/layouts/BaseLayout.astro new file mode 100644 index 00000000..d815cd48 --- /dev/null +++ b/src/landing-page/src/layouts/BaseLayout.astro @@ -0,0 +1,48 @@ +--- +import '@fontsource-variable/bricolage-grotesque'; +import '@fontsource-variable/ibm-plex-sans'; +import '@fontsource/ibm-plex-mono/400.css'; +import '../styles/global.css'; + +interface Props { + title: string; + description: string; +} + +const { title, description } = Astro.props; +const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined; +--- + + + + + + + + + {canonical && } + + + + + + {canonical && } + + + + + + + + + + + + {title} + + + + + diff --git a/src/landing-page/src/pages/index.astro b/src/landing-page/src/pages/index.astro new file mode 100644 index 00000000..be5d687b --- /dev/null +++ b/src/landing-page/src/pages/index.astro @@ -0,0 +1,506 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import RevealScript from '../components/RevealScript.astro'; + +const docsUrl = 'https://docs.cocoar.dev/modgud/'; +const quickstartUrl = `${docsUrl}getting-started/quickstart.html`; +const githubUrl = 'https://github.com/cocoar-dev/modgud'; +const roadmapUrl = `${githubUrl}/blob/develop/docs/roadmap.md`; + +const standards = ['OAuth 2.0', 'OpenID Connect', 'WebAuthn', 'DPoP', 'SAML federation', 'OpenTelemetry']; + +const accessMethods = [ + { + name: 'Passkeys', + note: 'Phishing-resistant sign-in with Touch ID, Windows Hello or a hardware key.', + icon: 'fingerprint', + }, + { + name: 'Passwordless', + note: 'Magic links and email OTP when a password is one credential too many.', + icon: 'spark', + }, + { + name: 'Authenticator apps', + note: 'Standards-based TOTP for users who already have a trusted setup.', + icon: 'dial', + }, + { + name: 'Enterprise SSO', + note: 'Connect Microsoft Entra ID, any OIDC provider or SAML identity provider.', + icon: 'building', + }, +]; + +const operations = [ + { + eyebrow: 'See', + title: 'Live operations', + text: 'OpenTelemetry metrics and traces, security events and a live activity feed keep the control plane observable.', + }, + { + eyebrow: 'Control', + title: 'Active sessions', + text: 'Device-aware session tracking gives users and admins a clear view of where an account is signed in.', + }, + { + eyebrow: 'Recover', + title: 'Break-glass CLI', + text: 'Bootstrap an admin, reset 2FA or rebuild projections even when the browser path is unavailable.', + }, + { + eyebrow: 'Respect', + title: 'GDPR workflows', + text: 'Self-service export and irreversible PII erasure preserve the audit chain without preserving the person.', + }, +]; +--- + + + + + + +
+
+
+
+

Self-hosted identity control plane

+

Identity, isolated by realm.
Access, shaped for every app.

+

+ Modgud is an OAuth 2.0 and OpenID Connect server for multi-tenant applications — with physical tenant isolation, app-aware permissions and every modern way in. +

+ +

+ Pre-1.0 + Active development · Apache 2.0 + What ships today +

+
+ + +
+
+ +
+
+

Built on standards, not lock-in

+
    + {standards.map((standard) =>
  • {standard}
  • )} +
+
+
+ +
+
+

Boundaries before features

+
+

One identity layer.
Clean lines everywhere else.

+

+ Multi-tenant identity is easy to start and hard to keep honest. Modgud makes realm, app and resource boundaries visible in the model — and enforceable in the architecture. +

+
+
+
+ +
+
+
+

Realm isolation

+

A database boundary, not a filter.

+

+ Every realm gets its own PostgreSQL database. Host-based routing selects the realm before a document session opens, so tenant separation does not depend on remembering a tenant_id predicate. +

+
    +
  • + + Physical database per realm +
  • +
  • + + Domain-based realm resolution +
  • +
  • + + Fresh defaults provisioned automatically +
  • +
+
+ + +
+
+ +
+
+ + +
+

App-aware authorization

+

Permissions arrive where they belong.

+

+ Apps own resource catalogs. Roles bind to an app. Tokens carry a familiar resource_access block narrowed to the current audience — not a bag of global claims every service has to interpret. +

+
    +
  • + + Granular resource:action permissions +
  • +
  • + + Exact, resource-admin and realm-admin gates +
  • +
  • + + Humans and service accounts share one model +
  • +
+
+
+
+ +
+
+
+

Every trusted way in

+

Let people sign in like it’s 2026.

+

+ Start with local accounts. Add passwordless, phishing-resistant authentication or your organisation’s existing identity provider — per realm, without changing the apps behind it. +

+
+ +
+ {accessMethods.map((method, index) => ( +
+ + {method.icon === 'fingerprint' && ( + + )} + {method.icon === 'spark' && ( + + )} + {method.icon === 'dial' && ( + + )} + {method.icon === 'building' && ( + + )} + +

{method.name}

+

{method.note}

+
+ ))} +
+
+
+ +
+
+
+

The .NET path is short

+

Your API already speaks the language.

+

+ Modgud uses standard JWT bearer validation and ships a small ASP.NET Core client that selects the current audience, projects roles and permissions, and adds a readable endpoint gate. +

+ + Read the resource server guide + + +
+ +
+
+
+ Program.cs + ASP.NET Core +
+
// One audience. One projection.
+builder.Services.AddModgudClient(options =>
+{
+    options.Authority = "https://id.acme.example";
+    options.Audience = "orders-api";
+});
+
+app.MapPost("/orders", CreateOrder)
+   .RequiresModgudPermission(
+       "orders:write");
+
+ JWT or reference token + 401 anonymous · 403 forbidden +
+
+
+
+ +
+
+
+

Built for the day after launch

+

Identity is an operation,
not a login screen.

+
+
+ {operations.map((item, index) => ( +
+

{item.eyebrow}

+

{item.title}

+ {item.text} +
+ ))} +
+
+
+ +
+
+
+ + +
+
+

The watcher of the bridge

+

“Who are you, and what brings you here?”

+

+ Modgud takes its name from Móðguðr, the guardian of Gjallarbrú in Norse mythology. She challenged every traveller crossing the bridge — a fitting namesake for the service deciding who may pass, and where. +

+
+
+
+ +
+
+

Take control of the crossing

+

Give every realm a boundary.
Give every app the right access.

+ +
+
+
+ + + + +
diff --git a/src/landing-page/src/styles/global.css b/src/landing-page/src/styles/global.css new file mode 100644 index 00000000..e548f708 --- /dev/null +++ b/src/landing-page/src/styles/global.css @@ -0,0 +1,2099 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + color-scheme: light; + --paper: #f7f9fc; + --surface: #ffffff; + --surface-soft: #eef3f8; + --ink: #172033; + --ink-soft: #27334b; + --steel: #525e76; + --muted: #68758b; + --faint: #8c97aa; + --line: #dce3eb; + --line-strong: #c8d2de; + --blue: #1988cf; + --blue-deep: #126da8; + --blue-soft: #e3f3fd; + --ice: #d8e9f5; + --copper: #b85e3b; + --copper-soft: #f6e8e0; + --success: #2f9b72; + --bridge: #182238; + --bridge-soft: #25324d; + --font-display: 'Bricolage Grotesque Variable', 'Segoe UI', sans-serif; + --font-body: 'IBM Plex Sans Variable', 'Segoe UI', sans-serif; + --font-code: 'IBM Plex Mono', 'Cascadia Code', monospace; + --max-width: 1200px; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --shadow-small: 0 14px 40px -26px rgb(23 32 51 / 30%); + --shadow-large: 0 36px 90px -44px rgb(23 32 51 / 34%); +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100dvh; + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.55; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +:where(h1, h2, h3, p, ul, ol, pre) { + margin: 0; +} + +:where(ul, ol) { + padding: 0; + list-style: none; +} + +:where(a) { + color: inherit; + text-decoration: none; +} + +:where(img, svg) { + display: block; + max-width: 100%; +} + +:where(button, input, textarea, select) { + font: inherit; +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--blue) 80%, white); + outline-offset: 4px; + border-radius: 4px; +} + +.skip-link { + position: fixed; + top: -100px; + left: 1rem; + z-index: 1000; + padding: 0.7rem 1rem; + border-radius: 8px; + background: var(--ink); + color: white; + font-weight: 650; +} + +.skip-link:focus-visible { + top: 1rem; +} + +.site-header { + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid rgb(220 227 235 / 72%); + background: rgb(247 249 252 / 88%); + backdrop-filter: blur(18px); +} + +.header-inner { + width: min(calc(100% - 40px), var(--max-width)); + min-height: 72px; + margin-inline: auto; + display: flex; + align-items: center; + gap: clamp(24px, 5vw, 64px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + flex: none; + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: -0.035em; +} + +.brand img { + width: 26px; + height: 34px; + object-fit: contain; +} + +.primary-nav { + display: flex; + align-items: center; + gap: clamp(20px, 3vw, 34px); + margin-left: auto; +} + +.primary-nav a { + position: relative; + color: var(--steel); + font-size: 0.9rem; + font-weight: 520; + transition: color 160ms ease; +} + +.primary-nav a::after { + content: ''; + position: absolute; + right: 0; + bottom: -7px; + left: 0; + height: 1.5px; + background: var(--blue); + transform: scaleX(0); + transform-origin: right; + transition: transform 180ms var(--ease-out); +} + +.primary-nav a:hover { + color: var(--ink); +} + +.primary-nav a:hover::after { + transform: scaleX(1); + transform-origin: left; +} + +.header-github { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid var(--line-strong); + border-radius: 9px; + background: var(--surface); + color: var(--ink-soft); + font-size: 0.86rem; + font-weight: 620; + box-shadow: 0 5px 15px -12px rgb(23 32 51 / 60%); + transition: + border-color 160ms ease, + transform 160ms ease; +} + +.header-github:hover { + border-color: var(--steel); + transform: translateY(-1px); +} + +.hero { + position: relative; + overflow: hidden; + padding: clamp(64px, 8vw, 108px) 0 clamp(70px, 9vw, 118px); + background: + radial-gradient(circle at 88% 12%, rgb(25 136 207 / 10%), transparent 30%), + linear-gradient(var(--paper), var(--paper)); +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgb(82 94 118 / 5%) 1px, transparent 1px), + linear-gradient(90deg, rgb(82 94 118 / 5%) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: linear-gradient(to bottom, black, transparent 72%); +} + +.hero-inner, +.section-inner, +.standards-inner, +.footer-inner, +.footer-bottom { + width: min(calc(100% - 40px), var(--max-width)); + margin-inline: auto; +} + +.hero-inner { + position: relative; +} + +.hero-copy { + max-width: 880px; +} + +.eyebrow, +.section-kicker { + color: var(--blue-deep); + font-size: 0.76rem; + font-weight: 690; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 10px; +} + +.eyebrow > span { + width: 26px; + height: 1.5px; + background: var(--blue); +} + +.hero h1 { + max-width: 15ch; + margin-top: 22px; + font-family: var(--font-display); + font-size: clamp(3rem, 7.2vw, 6.25rem); + font-variation-settings: 'wdth' 95; + font-weight: 670; + line-height: 0.95; + letter-spacing: -0.062em; + text-wrap: balance; +} + +.hero h1 em { + color: var(--steel); + font-style: normal; + font-weight: 460; +} + +.hero-lede { + max-width: 66ch; + margin-top: 28px; + color: var(--steel); + font-size: clamp(1.08rem, 1.7vw, 1.28rem); + line-height: 1.6; +} + +.hero-actions, +.cta-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 34px; +} + +.button { + display: inline-flex; + min-height: 48px; + align-items: center; + justify-content: center; + gap: 10px; + padding: 0 19px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 0.92rem; + font-weight: 670; + transition: + transform 170ms var(--ease-out), + background-color 170ms ease, + border-color 170ms ease, + box-shadow 170ms ease; +} + +.button:hover { + transform: translateY(-2px); +} + +.button-primary { + background: var(--blue-deep); + color: white; + box-shadow: 0 16px 30px -17px rgb(18 109 168 / 72%); +} + +.button-primary:hover { + background: #0f6399; + box-shadow: 0 20px 34px -17px rgb(18 109 168 / 82%); +} + +.button-secondary { + border-color: var(--line-strong); + background: rgb(255 255 255 / 74%); + color: var(--ink-soft); +} + +.button-secondary:hover { + border-color: var(--steel); + background: white; +} + +.hero-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 20px; + color: var(--muted); + font-family: var(--font-code); + font-size: 0.7rem; +} + +.hero-status span { + padding: 4px 8px; + border: 1px solid #e2c5b7; + border-radius: 999px; + background: var(--copper-soft); + color: #8f452b; +} + +.hero-status-link { + margin-left: 2px; + padding-left: 12px; + border-left: 1px solid var(--line-strong); + color: var(--blue-deep); + font-weight: 650; + transition: color 150ms ease; +} + +.hero-status-link::after { + content: '↗'; + display: inline-block; + margin-left: 5px; + transition: transform 160ms var(--ease-out); +} + +.hero-status-link:hover { + color: var(--ink); +} + +.hero-status-link:hover::after { + transform: translate(2px, -2px); +} + +.identity-bridge { + position: relative; + margin-top: clamp(52px, 7vw, 84px); + overflow: hidden; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 22px; + background: + radial-gradient(circle at 48% 46%, rgb(25 136 207 / 14%), transparent 28%), + var(--bridge); + color: #edf4fb; + box-shadow: 0 38px 90px -44px rgb(12 21 38 / 75%); +} + +.identity-bridge::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgb(255 255 255 / 3.2%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 3.2%) 1px, transparent 1px); + background-size: 38px 38px; +} + +.bridge-caption, +.bridge-footer { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + color: #aebbd0; + font-family: var(--font-code); + font-size: 0.65rem; + letter-spacing: 0.055em; + text-transform: uppercase; +} + +.bridge-caption { + border-bottom: 1px solid rgb(255 255 255 / 8%); +} + +.bridge-live { + display: flex; + align-items: center; + gap: 7px; +} + +.bridge-live i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #57d0a4; + box-shadow: 0 0 0 4px rgb(87 208 164 / 12%); +} + +.topology { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.4fr) minmax(205px, 0.9fr); + min-height: 336px; + align-items: stretch; + gap: 0; + padding: 28px clamp(18px, 3vw, 36px); +} + +.topology-column { + position: relative; + z-index: 4; + display: flex; + flex-direction: column; + justify-content: center; + gap: 12px; +} + +.topology-label { + position: absolute; + top: 0; + color: #7989a4; + font-family: var(--font-code); + font-size: 0.6rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.topology-realms .topology-label { + left: 0; +} + +.topology-apps .topology-label { + right: 0; +} + +.realm-node, +.app-node { + min-height: 70px; + border: 1px solid rgb(255 255 255 / 10%); + background: rgb(37 50 77 / 88%); + box-shadow: 0 15px 36px -26px rgb(0 0 0 / 70%); +} + +.realm-node { + display: grid; + grid-template-columns: 38px 1fr auto; + align-items: center; + gap: 11px; + padding: 12px 13px; + border-radius: 11px 2px 2px 11px; +} + +.realm-node strong, +.realm-node small, +.app-node strong, +.app-node small { + display: block; +} + +.realm-node strong, +.app-node strong { + color: #f4f8fc; + font-size: 0.78rem; + font-weight: 620; +} + +.realm-node small, +.app-node small { + margin-top: 2px; + color: #8ea0ba; + font-family: var(--font-code); + font-size: 0.55rem; +} + +.node-icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 9px; + background: rgb(25 136 207 / 15%); + color: #72c6f5; +} + +.node-icon svg { + width: 22px; + fill: none; + stroke: currentColor; + stroke-width: 1.35; +} + +.node-state { + width: 7px; + height: 7px; + border-radius: 50%; + background: #55c89e; +} + +.app-node { + display: grid; + grid-template-columns: 36px 1fr; + align-items: center; + gap: 11px; + padding: 11px 12px; + border-radius: 2px 11px 11px 2px; +} + +.app-glyph { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 8px; +} + +.app-glyph svg { + width: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.45; + stroke-linecap: round; + stroke-linejoin: round; +} + +.app-glyph-portal { + background: rgb(25 136 207 / 14%); + color: #72c6f5; +} + +.app-glyph-api { + background: rgb(184 94 59 / 16%); + color: #e49877; +} + +.app-glyph-admin { + background: rgb(87 208 164 / 13%); + color: #74d6b3; +} + +.bridge-span { + position: relative; + min-height: 280px; +} + +.bridge-deck { + position: absolute; + right: 0; + left: 0; + height: 1px; + background: linear-gradient(90deg, #4b627f, #5ca9d8 45%, #4b627f); +} + +.bridge-deck::before, +.bridge-deck::after { + content: ''; + position: absolute; + top: -3px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #6fb9e6; + box-shadow: 0 0 0 5px rgb(111 185 230 / 8%); +} + +.bridge-deck::before { + left: 0; +} + +.bridge-deck::after { + right: 0; +} + +.bridge-deck-top { + top: 37%; +} + +.bridge-deck-bottom { + top: 67%; +} + +.bridge-permission { + position: absolute; + top: -23px; + left: 50%; + padding: 3px 7px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 5px; + background: #202c45; + color: #92a5c1; + font-family: var(--font-code); + font-size: 0.5rem; + transform: translateX(-50%); +} + +.packet { + position: absolute; + top: -3px; + left: 5%; + z-index: 5; + width: 7px; + height: 7px; + border-radius: 50%; + background: #d8f2ff; + box-shadow: + 0 0 0 4px rgb(114 198 245 / 15%), + 0 0 14px 5px rgb(114 198 245 / 20%); + animation: bridge-traffic 4.8s linear infinite; +} + +.packet-two { + background: #efb49b; + box-shadow: + 0 0 0 4px rgb(228 152 119 / 14%), + 0 0 14px 5px rgb(228 152 119 / 18%); + animation-delay: -2.4s; +} + +@keyframes bridge-traffic { + from { + left: 4%; + } + to { + left: calc(96% - 7px); + } +} + +.bridge-gate { + position: absolute; + top: 50%; + left: 50%; + z-index: 7; + display: grid; + width: 114px; + height: 148px; + place-items: center; + padding: 15px 8px 12px; + border: 1px solid rgb(130 199 238 / 24%); + border-radius: 56px 56px 18px 18px; + background: linear-gradient(180deg, #2a3854, #1e2a42); + box-shadow: + 0 24px 50px -24px rgb(0 0 0 / 80%), + inset 0 1px rgb(255 255 255 / 8%); + transform: translate(-50%, -50%); +} + +.gate-halo { + position: absolute; + inset: -18px; + z-index: -1; + border: 1px solid rgb(114 198 245 / 12%); + border-radius: 70px 70px 30px 30px; +} + +.bridge-gate img { + width: 48px; + height: 62px; + object-fit: contain; + filter: brightness(1.5); +} + +.bridge-gate strong { + margin-top: -3px; + font-family: var(--font-display); + font-size: 0.67rem; + letter-spacing: 0.08em; +} + +.bridge-gate small { + margin-top: -10px; + color: #8193ae; + font-family: var(--font-code); + font-size: 0.47rem; +} + +.bridge-footer { + justify-content: flex-start; + gap: 26px; + border-top: 1px solid rgb(255 255 255 / 8%); + text-transform: none; +} + +.bridge-footer span { + display: flex; + align-items: center; + gap: 7px; +} + +.footer-dot { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.footer-dot-blue { + background: #72c6f5; +} + +.footer-dot-copper { + background: #e49877; +} + +.standards { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.standards-inner { + display: grid; + grid-template-columns: auto 1fr; + min-height: 102px; + align-items: center; + gap: clamp(30px, 6vw, 74px); +} + +.standards p { + color: var(--muted); + font-size: 0.77rem; + font-weight: 610; + white-space: nowrap; +} + +.standards ul { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.standards li { + color: var(--steel); + font-family: var(--font-code); + font-size: clamp(0.63rem, 1vw, 0.75rem); +} + +.section { + padding: clamp(86px, 11vw, 150px) 0; +} + +.section-intro { + background: var(--paper); +} + +.intro-grid { + display: grid; + grid-template-columns: 0.55fr 1.45fr; + align-items: start; + gap: clamp(36px, 8vw, 110px); +} + +.intro-grid h2, +.feature-copy h2, +.access-heading h2, +.integration-copy h2, +.operations-heading h2, +.name-copy h2, +.cta-inner h2 { + font-family: var(--font-display); + font-weight: 640; + line-height: 1.02; + letter-spacing: -0.045em; + text-wrap: balance; +} + +.intro-grid h2 { + font-size: clamp(2.65rem, 5vw, 4.8rem); +} + +.intro-grid > div > p { + max-width: 60ch; + margin-top: 27px; + color: var(--steel); + font-size: clamp(1rem, 1.5vw, 1.16rem); + line-height: 1.68; +} + +.feature-section { + overflow: hidden; + padding: clamp(82px, 10vw, 140px) 0; +} + +.feature-isolation { + border-top: 1px solid var(--line); + background: var(--surface); +} + +.feature-permissions { + background: var(--surface-soft); +} + +.feature-grid { + display: grid; + grid-template-columns: minmax(0, 0.86fr) minmax(0, 1.14fr); + align-items: center; + gap: clamp(54px, 8vw, 112px); +} + +.feature-grid-reverse { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); +} + +.feature-copy h2 { + max-width: 12ch; + margin-top: 17px; + font-size: clamp(2.35rem, 4.2vw, 4.15rem); +} + +.feature-copy > p:not(.section-kicker) { + max-width: 52ch; + margin-top: 25px; + color: var(--steel); + font-size: 1.05rem; + line-height: 1.68; +} + +code { + font-family: var(--font-code); + font-size: 0.86em; +} + +.check-list { + display: grid; + gap: 13px; + margin-top: 30px; +} + +.check-list li { + display: flex; + align-items: center; + gap: 11px; + color: var(--ink-soft); + font-size: 0.94rem; + font-weight: 520; +} + +.check-list svg { + width: 20px; + height: 20px; + flex: none; + padding: 3px; + border-radius: 50%; + background: var(--blue-soft); + fill: none; + stroke: var(--blue-deep); + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.isolation-visual { + position: relative; + padding: clamp(18px, 3vw, 28px); + border: 1px solid var(--line); + border-radius: 18px; + background: #fbfcfe; + box-shadow: var(--shadow-large); +} + +.isolation-visual::before { + content: ''; + position: absolute; + z-index: -1; + inset: 22px -18px -18px 22px; + border-radius: 18px; + background: + linear-gradient(var(--ice) 1px, transparent 1px), + linear-gradient(90deg, var(--ice) 1px, transparent 1px), + #eaf2f8; + background-size: 20px 20px; +} + +.isolation-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 17px; + border-bottom: 1px solid var(--line); +} + +.isolation-header span { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 650; +} + +.isolation-header small { + padding: 4px 8px; + border-radius: 999px; + background: #e5f4ee; + color: #277c5e; + font-family: var(--font-code); + font-size: 0.56rem; +} + +.realm-row { + display: grid; + grid-template-columns: 42px minmax(100px, 1fr) auto; + align-items: center; + gap: 13px; + padding: 17px 2px; + border-bottom: 1px solid var(--line); +} + +.realm-letter { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: 9px; + font-family: var(--font-display); + font-size: 0.8rem; + font-weight: 700; +} + +.realm-letter-a { + background: var(--blue-soft); + color: var(--blue-deep); +} + +.realm-letter-n { + background: var(--copper-soft); + color: #9b4c2e; +} + +.realm-letter-c { + background: #e6eee8; + color: #42755d; +} + +.realm-info strong, +.realm-info small { + display: block; +} + +.realm-info strong { + font-size: 0.82rem; + font-weight: 650; +} + +.realm-info small { + margin-top: 2px; + color: var(--muted); + font-size: 0.65rem; +} + +.database-pill { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface); + color: var(--steel); + font-family: var(--font-code); + font-size: 0.57rem; +} + +.database-pill i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--blue); +} + +.isolation-note { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + color: var(--muted); + font-size: 0.7rem; +} + +.isolation-note svg { + width: 18px; + fill: none; + stroke: var(--success); + stroke-width: 1.5; +} + +.permission-visual { + display: grid; + grid-template-columns: 36% 64%; + min-height: 440px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-large); +} + +.permission-sidebar { + padding: 25px 18px; + border-right: 1px solid var(--line); + background: #f8fafc; +} + +.permission-sidebar > p { + color: var(--faint); + font-family: var(--font-code); + font-size: 0.54rem; + letter-spacing: 0.08em; +} + +.permission-person { + display: flex; + align-items: center; + gap: 10px; + margin: 22px 0 30px; +} + +.permission-person > i { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 50%; + background: var(--steel); + color: white; + font-family: var(--font-code); + font-size: 0.6rem; + font-style: normal; +} + +.permission-person strong, +.permission-person small { + display: block; +} + +.permission-person strong { + font-size: 0.7rem; +} + +.permission-person small { + margin-top: 2px; + color: var(--muted); + font-size: 0.54rem; +} + +.permission-path { + display: flex; + align-items: center; + gap: 9px; + margin-top: 7px; + padding: 8px 10px; + border-radius: 7px; + color: var(--muted); + font-size: 0.66rem; +} + +.permission-path i { + width: 7px; + height: 7px; + border: 1px solid var(--faint); + border-radius: 2px; +} + +.permission-path.active { + background: var(--blue-soft); + color: var(--blue-deep); + font-weight: 630; +} + +.permission-path.active i { + border-color: var(--blue); + background: var(--blue); +} + +.permission-main { + display: flex; + min-width: 0; + flex-direction: column; + padding: 28px clamp(18px, 3vw, 30px); +} + +.permission-main-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.permission-main-head span small, +.permission-main-head span strong { + display: block; +} + +.permission-main-head span small { + color: var(--faint); + font-family: var(--font-code); + font-size: 0.52rem; + letter-spacing: 0.08em; +} + +.permission-main-head span strong { + margin-top: 3px; + font-family: var(--font-display); + font-size: 1.1rem; +} + +.permission-main-head > i { + padding: 5px 8px; + border-radius: 999px; + background: #e5f4ee; + color: #277c5e; + font-family: var(--font-code); + font-size: 0.52rem; + font-style: normal; +} + +.claim-block { + margin-top: 24px; + padding: 19px; + overflow: hidden; + border-radius: 11px; + background: var(--bridge); + color: #bdc9d9; + font-family: var(--font-code); + font-size: clamp(0.57rem, 0.95vw, 0.67rem); + line-height: 1.75; + white-space: nowrap; +} + +.code-indent { + padding-left: 1.3em; +} + +.code-indent-2 { + padding-left: 2.6em; +} + +.code-indent-3 { + padding-left: 3.9em; +} + +.code-key { + color: #7bc9f2; +} + +.code-value { + color: #e9a98d; +} + +.claim-note { + display: flex; + align-items: center; + gap: 7px; + margin-top: auto; + padding-top: 18px; + color: var(--muted); + font-size: 0.62rem; +} + +.claim-note span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); +} + +.access-section { + padding: clamp(92px, 12vw, 160px) 0; + background: var(--bridge); + color: white; +} + +.access-heading { + display: grid; + grid-template-columns: 0.6fr 1.1fr 1fr; + align-items: start; + gap: clamp(24px, 5vw, 66px); +} + +.access-heading .section-kicker { + color: #77c7f2; +} + +.access-heading h2 { + max-width: 12ch; + font-size: clamp(2.5rem, 4.6vw, 4.35rem); +} + +.access-heading > p:not(.section-kicker) { + padding-top: 6px; + color: #aebbd0; + line-height: 1.7; +} + +.access-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + margin-top: clamp(56px, 8vw, 90px); + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 17px; + overflow: hidden; + background: rgb(255 255 255 / 10%); +} + +.access-card { + min-height: 280px; + padding: clamp(23px, 3vw, 32px); + background: var(--bridge); + transition: background-color 180ms ease; +} + +.access-card:hover { + background: var(--bridge-soft); +} + +.access-icon { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 12px; +} + +.access-icon svg { + width: 25px; + fill: none; + stroke: currentColor; + stroke-width: 1.45; + stroke-linecap: round; + stroke-linejoin: round; +} + +.access-icon-fingerprint { + background: rgb(25 136 207 / 15%); + color: #7ecaf3; +} + +.access-icon-spark { + background: rgb(184 94 59 / 17%); + color: #eca283; +} + +.access-icon-dial { + background: rgb(87 208 164 / 14%); + color: #73d7b3; +} + +.access-icon-building { + background: rgb(182 164 229 / 14%); + color: #bcaee6; +} + +.access-card h3 { + margin-top: 45px; + font-family: var(--font-display); + font-size: 1.22rem; + font-weight: 620; + letter-spacing: -0.025em; +} + +.access-card p { + margin-top: 12px; + color: #9facbf; + font-size: 0.9rem; + line-height: 1.62; +} + +.integration-section { + padding: clamp(92px, 12vw, 160px) 0; + background: var(--surface); +} + +.integration-grid { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + align-items: center; + gap: clamp(60px, 9vw, 126px); +} + +.integration-copy h2 { + max-width: 12ch; + margin-top: 17px; + font-size: clamp(2.4rem, 4.5vw, 4.25rem); +} + +.integration-copy > p:not(.section-kicker) { + max-width: 49ch; + margin-top: 24px; + color: var(--steel); + line-height: 1.7; +} + +.text-link { + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 29px; + color: var(--blue-deep); + font-size: 0.9rem; + font-weight: 670; +} + +.text-link svg { + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 160ms var(--ease-out); +} + +.text-link:hover svg { + transform: translateX(4px); +} + +.code-window { + overflow: hidden; + border: 1px solid #293752; + border-radius: 16px; + background: var(--bridge); + box-shadow: 0 35px 75px -40px rgb(23 32 51 / 74%); +} + +.code-window-head { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 13px 16px; + border-bottom: 1px solid rgb(255 255 255 / 8%); + color: #8e9db5; + font-family: var(--font-code); + font-size: 0.58rem; +} + +.code-window-head > div { + display: flex; + gap: 6px; +} + +.code-window-head i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #506079; +} + +.code-window-head i:nth-child(1) { + background: #b85e3b; +} + +.code-window-head i:nth-child(2) { + background: #c39b4c; +} + +.code-window-head i:nth-child(3) { + background: #4c9f7d; +} + +.code-window-head small { + justify-self: end; + font-size: 0.55rem; +} + +.code-window pre { + padding: clamp(22px, 4vw, 38px); + overflow-x: auto; + color: #d4deea; + font-family: var(--font-code); + font-size: clamp(0.69rem, 1.2vw, 0.84rem); + line-height: 1.8; +} + +.code-muted { + color: #71819a; +} + +.code-function { + color: #81cff7; +} + +.code-string { + color: #e9a98d; +} + +.code-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 13px 18px; + border-top: 1px solid rgb(255 255 255 / 8%); + color: #8392aa; + font-family: var(--font-code); + font-size: 0.55rem; +} + +.code-result span:first-child { + display: flex; + align-items: center; + gap: 7px; + color: #9eb0c8; +} + +.code-result i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #57d0a4; +} + +.operations-section { + padding: clamp(90px, 11vw, 150px) 0; + border-top: 1px solid var(--line); + background: var(--paper); +} + +.operations-heading { + display: grid; + grid-template-columns: 0.6fr 1.4fr; + align-items: start; + gap: clamp(30px, 8vw, 110px); +} + +.operations-heading h2 { + font-size: clamp(2.5rem, 4.7vw, 4.5rem); +} + +.operations-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + margin-top: clamp(60px, 8vw, 92px); + border-top: 1px solid var(--line-strong); + border-bottom: 1px solid var(--line-strong); +} + +.operation-card { + min-height: 240px; + padding: 25px clamp(18px, 2.5vw, 29px) 30px; + border-left: 1px solid var(--line); +} + +.operation-card:first-child { + border-left: 0; +} + +.operation-card > p { + color: var(--copper); + font-family: var(--font-code); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.operation-card h3 { + margin-top: 68px; + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 630; + letter-spacing: -0.025em; +} + +.operation-card > span { + display: block; + margin-top: 11px; + color: var(--muted); + font-size: 0.86rem; + line-height: 1.6; +} + +.name-section { + padding: clamp(92px, 12vw, 160px) 0; + background: #edf2f7; +} + +.name-grid { + display: grid; + grid-template-columns: 0.7fr 1.3fr; + align-items: center; + gap: clamp(50px, 10vw, 145px); +} + +.name-mark { + position: relative; + display: grid; + min-height: 340px; + place-items: center; +} + +.name-mark::before, +.name-mark::after, +.name-ring { + content: ''; + position: absolute; + border: 1px solid #cbd6e2; + border-radius: 50%; +} + +.name-mark::before { + width: 300px; + height: 300px; +} + +.name-mark::after { + width: 220px; + height: 220px; +} + +.name-ring { + width: 140px; + height: 140px; + background: rgb(255 255 255 / 46%); +} + +.name-mark img { + position: relative; + z-index: 2; + width: 105px; + height: 138px; + object-fit: contain; +} + +.name-copy h2 { + max-width: 13ch; + margin-top: 19px; + font-size: clamp(2.4rem, 4.4vw, 4.25rem); +} + +.name-copy > p:not(.section-kicker) { + max-width: 58ch; + margin-top: 26px; + color: var(--steel); + font-size: 1.04rem; + line-height: 1.72; +} + +.final-cta { + position: relative; + overflow: hidden; + padding: clamp(88px, 11vw, 144px) 0; + background: var(--bridge); + color: white; +} + +.final-cta::before { + content: ''; + position: absolute; + top: -180px; + right: -60px; + width: 500px; + height: 500px; + border: 1px solid rgb(114 198 245 / 12%); + border-radius: 50%; + box-shadow: + 0 0 0 70px rgb(114 198 245 / 3%), + 0 0 0 140px rgb(114 198 245 / 2%); +} + +.cta-inner { + position: relative; + z-index: 2; +} + +.cta-inner .section-kicker { + color: #77c7f2; +} + +.cta-inner h2 { + max-width: 18ch; + margin-top: 20px; + font-size: clamp(2.75rem, 5.4vw, 5.2rem); +} + +.button-light { + background: white; + color: var(--ink); +} + +.button-light:hover { + background: #eaf5fc; +} + +.button-dark-ghost { + border-color: rgb(255 255 255 / 22%); + color: white; +} + +.button-dark-ghost:hover { + border-color: rgb(255 255 255 / 46%); + background: rgb(255 255 255 / 5%); +} + +.site-footer { + padding: 64px 0 28px; + border-top: 1px solid #2a3751; + background: #121b2d; + color: #c4cfdd; +} + +.footer-inner { + display: grid; + grid-template-columns: 2fr repeat(3, 0.7fr); + gap: clamp(30px, 7vw, 90px); +} + +.footer-brand { + color: white; +} + +.footer-brand img { + filter: brightness(1.45); +} + +.footer-inner > div > p { + max-width: 35ch; + margin-top: 15px; + color: #77879e; + font-size: 0.84rem; +} + +.footer-inner nav { + display: flex; + flex-direction: column; + gap: 10px; +} + +.footer-inner nav strong { + margin-bottom: 4px; + color: white; + font-family: var(--font-code); + font-size: 0.63rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.footer-inner nav a { + color: #8f9db1; + font-size: 0.8rem; + transition: color 150ms ease; +} + +.footer-inner nav a:hover { + color: white; +} + +.footer-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 55px; + padding-top: 24px; + border-top: 1px solid #27334b; + color: #65758d; + font-family: var(--font-code); + font-size: 0.57rem; +} + +html.js [data-reveal] { + opacity: 0; +} + +@media (prefers-reduced-motion: no-preference) { + html.js [data-reveal] { + transform: translateY(18px); + transition: + opacity 680ms var(--ease-out), + transform 680ms var(--ease-out); + transition-delay: var(--reveal-delay, 0ms); + } +} + +html.js [data-reveal].is-visible { + opacity: 1; + transform: none; +} + +@media (min-width: 961px) { + .hero { + padding-top: clamp(52px, 4vw, 64px); + } + + .identity-bridge { + margin-top: clamp(28px, 3vw, 42px); + } + + html.js .identity-bridge[data-reveal] { + opacity: 1; + transform: none; + } +} + +@media (max-width: 960px) { + .primary-nav { + display: none; + } + + .header-github { + margin-left: auto; + } + + .standards-inner { + grid-template-columns: 1fr; + gap: 15px; + padding-block: 24px; + } + + .standards ul { + flex-wrap: wrap; + justify-content: flex-start; + gap: 12px 24px; + } + + .intro-grid, + .feature-grid, + .feature-grid-reverse, + .integration-grid, + .name-grid { + grid-template-columns: 1fr; + } + + .intro-grid { + gap: 24px; + } + + .intro-grid > div { + max-width: 760px; + } + + .feature-copy { + max-width: 680px; + } + + .feature-grid-reverse .permission-visual { + order: 2; + } + + .feature-grid-reverse .feature-copy { + order: 1; + } + + .isolation-visual, + .permission-visual, + .code-window { + width: min(100%, 720px); + } + + .access-heading { + grid-template-columns: 0.7fr 1.3fr; + } + + .access-heading > p:not(.section-kicker) { + grid-column: 2; + } + + .access-grid { + grid-template-columns: 1fr 1fr; + } + + .operations-grid { + grid-template-columns: 1fr 1fr; + } + + .operation-card:nth-child(3) { + border-left: 0; + border-top: 1px solid var(--line); + } + + .operation-card:nth-child(4) { + border-top: 1px solid var(--line); + } + + .footer-inner { + grid-template-columns: 1.5fr repeat(3, 0.7fr); + gap: 32px; + } +} + +@media (max-width: 720px) { + .header-inner, + .hero-inner, + .section-inner, + .standards-inner, + .footer-inner, + .footer-bottom { + width: min(calc(100% - 32px), var(--max-width)); + } + + .hero h1 br { + display: none; + } + + .bridge-caption, + .bridge-footer { + padding-inline: 15px; + } + + .topology { + grid-template-columns: 1fr; + min-height: 0; + padding: 22px 15px 26px; + } + + .topology-column { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; + padding-top: 32px; + } + + .topology-apps { + grid-template-columns: repeat(3, 1fr); + } + + .topology-label, + .topology-apps .topology-label { + top: 9px; + right: auto; + left: 0; + } + + .realm-node, + .app-node { + border-radius: 9px; + } + + .app-node { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + } + + .bridge-span { + min-height: 160px; + } + + .bridge-deck { + top: 0; + bottom: 0; + width: 1px; + height: auto; + background: linear-gradient(#4b627f, #5ca9d8 45%, #4b627f); + } + + .bridge-deck::before, + .bridge-deck::after { + left: -3px; + } + + .bridge-deck::before { + top: 0; + } + + .bridge-deck::after { + top: auto; + right: auto; + bottom: 0; + } + + .bridge-deck-top { + right: auto; + left: 43%; + } + + .bridge-deck-bottom { + right: auto; + left: 57%; + } + + .bridge-permission, + .packet { + display: none; + } + + .bridge-gate { + width: 104px; + height: 128px; + } + + .bridge-gate img { + width: 40px; + height: 53px; + } + + .bridge-footer { + flex-wrap: wrap; + gap: 8px 18px; + } + + .access-heading, + .operations-heading { + grid-template-columns: 1fr; + gap: 20px; + } + + .access-heading > p:not(.section-kicker) { + grid-column: auto; + max-width: 60ch; + } + + .name-mark { + min-height: 280px; + } + + .name-mark::before { + width: 260px; + height: 260px; + } + + .name-mark::after { + width: 190px; + height: 190px; + } + + .footer-inner { + grid-template-columns: 1.5fr 1fr 1fr; + } + + .footer-inner > div { + grid-column: 1 / -1; + } +} + +@media (max-width: 560px) { + .header-inner { + min-height: 64px; + } + + .header-github span { + display: none; + } + + .header-github { + padding: 9px; + } + + .hero { + padding-top: 52px; + } + + .hero h1 { + font-size: clamp(2.72rem, 14vw, 4.15rem); + } + + .hero-actions, + .cta-actions { + align-items: stretch; + flex-direction: column; + } + + .button { + width: 100%; + } + + .bridge-caption span:first-child { + display: none; + } + + .bridge-caption { + justify-content: flex-end; + } + + .topology-apps { + grid-template-columns: 1fr; + } + + .app-node { + grid-template-columns: 36px 1fr; + justify-items: start; + text-align: left; + } + + .bridge-footer span { + width: 100%; + } + + .realm-row { + grid-template-columns: 38px 1fr; + } + + .realm-letter { + width: 36px; + height: 36px; + } + + .database-pill { + grid-column: 2; + width: fit-content; + } + + .permission-visual { + grid-template-columns: 1fr; + min-height: 0; + } + + .permission-sidebar { + display: none; + } + + .permission-main { + min-height: 410px; + } + + .access-grid, + .operations-grid { + grid-template-columns: 1fr; + } + + .access-card { + min-height: 240px; + } + + .access-card h3 { + margin-top: 35px; + } + + .operation-card, + .operation-card:nth-child(2), + .operation-card:nth-child(4) { + min-height: 210px; + border-top: 1px solid var(--line); + border-left: 0; + } + + .operation-card:first-child { + border-top: 0; + } + + .operation-card h3 { + margin-top: 46px; + } + + .code-result { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .footer-inner { + grid-template-columns: 1fr 1fr; + } + + .footer-inner > div { + grid-column: 1 / -1; + } + + .footer-bottom { + align-items: flex-start; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + html.js [data-reveal] { + opacity: 1 !important; + transform: none !important; + } +} diff --git a/src/landing-page/tsconfig.json b/src/landing-page/tsconfig.json new file mode 100644 index 00000000..bcbf8b50 --- /dev/null +++ b/src/landing-page/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} From 91722763e69445dcdc3f9758095b7f8f42f9231d Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 09:57:52 +0200 Subject: [PATCH 02/34] feat(landing): sharpen product positioning --- src/landing-page/src/pages/index.astro | 18 ++++++----- src/landing-page/src/styles/global.css | 43 ++++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/landing-page/src/pages/index.astro b/src/landing-page/src/pages/index.astro index be5d687b..0433e748 100644 --- a/src/landing-page/src/pages/index.astro +++ b/src/landing-page/src/pages/index.astro @@ -91,9 +91,10 @@ const operations = [
diff --git a/src/landing-page/src/styles/global.css b/src/landing-page/src/styles/global.css index e548f708..0be99e5a 100644 --- a/src/landing-page/src/styles/global.css +++ b/src/landing-page/src/styles/global.css @@ -268,11 +268,22 @@ body { font-weight: 460; } -.hero-lede { +.hero-thesis { max-width: 66ch; margin-top: 28px; + color: var(--ink-soft); + font-family: var(--font-display); + font-size: clamp(1.18rem, 2vw, 1.5rem); + font-weight: 650; + letter-spacing: -0.025em; + line-height: 1.35; +} + +.hero-lede { + max-width: 66ch; + margin-top: 12px; color: var(--steel); - font-size: clamp(1.08rem, 1.7vw, 1.28rem); + font-size: clamp(1.02rem, 1.45vw, 1.16rem); line-height: 1.6; } @@ -740,24 +751,36 @@ body { .standards-inner { display: grid; - grid-template-columns: auto 1fr; - min-height: 102px; + grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr); + min-height: 116px; align-items: center; gap: clamp(30px, 6vw, 74px); } -.standards p { +.standards-copy { + display: grid; + gap: 4px; +} + +.standards-copy p { + color: var(--ink-soft); + font-family: var(--font-display); + font-size: 1rem; + font-weight: 670; + letter-spacing: -0.02em; +} + +.standards-copy span { color: var(--muted); - font-size: 0.77rem; - font-weight: 610; - white-space: nowrap; + font-size: 0.72rem; } .standards ul { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; - gap: 18px; + justify-content: flex-end; + gap: 12px clamp(18px, 2.4vw, 30px); } .standards li { From ee7242ea21361e680fd3c778ddcb95ff3799adf2 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 14:57:18 +0200 Subject: [PATCH 03/34] feat: enforce realm-owned jobs and sessions --- docs/admin/applications.md | 3 +- docs/admin/oauth-clients.md | 12 +- docs/admin/realm-settings.md | 24 +- docs/admin/scheduled-jobs.md | 68 ++++- docs/contribute/testing/automated-tests.md | 4 +- docs/end-user/profile.md | 13 +- docs/integrate/cookies-and-sessions.md | 61 ++-- docs/operate/database.md | 3 +- docs/platform/settings.md | 15 +- docs/reference/auth-api.md | 8 +- .../CocoarNativeGrantFlowTests.cs | 51 +++- .../Authorization/UserInfoPerAudienceTests.cs | 13 + .../ColdStart/ScheduledJobsTenancyTests.cs | 259 +++++++++++++++++ .../Modgud.Api.Tests/Security/MfaTests.cs | 19 ++ .../Security/SecurityAuditWave1Tests.cs | 72 ++++- .../Admin/Jobs/AccountLifecycleSweepJob.cs | 79 ++--- .../Features/Admin/Jobs/DcrGcJob.cs | 55 ++-- .../Admin/Jobs/JobRunHistoryRetentionJob.cs | 61 +--- .../Features/Admin/Jobs/JobsEndpoints.cs | 23 +- .../Features/Admin/Jobs/SessionPruneJob.cs | 22 ++ .../Admin/Jobs/SigningKeyJanitorJob.cs | 76 ++--- .../Jobs/SystemJobRunHistoryRetentionJob.cs | 39 +++ .../Auth/OAuth/AuthorizationEndpoints.cs | 134 +++++++-- .../Features/Inbox/InboxRetentionJob.cs | 53 +--- .../HealthChecks/MartenSchemaHealthCheck.cs | 14 +- src/dotnet/Modgud.Api/Program.cs | 88 +++--- .../Realtime/BrowserSessionHubFilter.cs | 87 ++++++ .../Applications/ApplicationSettingsDtos.cs | 7 + .../DTOs/OAuth/OAuthClientDtos.cs | 9 +- .../DTOs/RealmSettings/RealmSettingsDtos.cs | 4 + .../DTOs/RealmSettings/SessionPolicyDtos.cs | 27 ++ .../Scheduling/IJobsService.cs | 8 +- .../Services/OAuthAdminMapping.cs | 28 ++ .../Services/OAuthAdminService.cs | 13 + .../Api/Account/AccountEndpoints.cs | 20 +- .../Api/Account/BootstrapEndpoints.cs | 2 - .../Api/Account/EmailOtpEndpoints.cs | 2 - .../Api/Account/MagicLinkEndpoints.cs | 2 - .../Api/Account/MfaEndpoints.cs | 2 - .../Api/Account/PasskeyEndpoints.cs | 2 - .../Api/Account/SessionEndpoints.cs | 60 +++- .../Api/Admin/AdminSessionEndpoints.cs | 6 + .../Api/ExternalAuth/ExternalAuthEndpoints.cs | 6 - .../Api/ExternalAuth/Saml/SamlLoginFlow.cs | 7 - .../ApplicationSettingsService.cs | 34 +++ .../Domain/ClientSession.cs | 36 +++ .../Domain/UserSession.cs | 23 +- .../Modgud.Authentication/Gdpr/GdprDtos.cs | 5 + .../Modgud.Authentication/Gdpr/GdprService.cs | 24 +- .../RealmSettings/RealmSettingsService.cs | 92 ++++++ .../BrowserSessionConnectionRegistry.cs | 58 ++++ .../Sessions/BrowserSessionCookieEvents.cs | 119 ++++++++ .../Sessions/ClientSessionService.cs | 254 ++++++++++++++++ .../Sessions/IClientSessionService.cs | 30 ++ .../Sessions/ISessionService.cs | 9 + .../Sessions/SessionClaimTypes.cs | 7 + .../Sessions/SessionDtos.cs | 18 ++ .../Sessions/SessionService.cs | 101 ++++++- .../Sessions/SessionTracker.cs | 30 -- .../Sessions/UserAccessRevoker.cs | 4 + .../Setup/MartenStoreOptionsExtensions.cs | 13 +- .../Applications/ApplicationSettings.cs | 12 + .../Applications/EffectiveSettings.cs | 16 + .../Applications/OAuthApplicationKeys.cs | 2 + .../RealmSettings/RealmSettings.cs | 9 + .../Modgud.Domain/Realms/SessionPolicies.cs | 32 ++ .../DependencyInjection.cs | 9 + .../OpenIddict/IOAuthGrantRevoker.cs | 8 + .../OpenIddict/IRefreshTokenReuseObserver.cs | 14 + .../OpenIddict/OpenIddictGrantRevoker.cs | 20 ++ .../RefreshTokenReuseAuditHandler.cs | 23 ++ .../Realms/RealmProvisioningService.cs | 32 ++ .../Scheduling/IJobRegistry.cs | 8 +- .../Scheduling/JobConfig.cs | 20 +- .../Scheduling/JobRegistration.cs | 37 ++- .../Scheduling/JobRunHistoryEntry.cs | 6 +- .../JobRunHistoryRetentionService.cs | 25 +- .../Scheduling/JobRunListener.cs | 54 +++- .../Scheduling/JobsService.cs | 255 ++++++++++------ .../Scheduling/RealmJobScheduler.cs | 275 ++++++++++++++++++ .../SchedulingDependencyInjection.cs | 169 +++++------ .../Application/OAuthAdminMappingTests.cs | 42 +++ .../Applications/EffectiveSettingsTests.cs | 40 +++ .../Domain/ClientSessionTests.cs | 31 ++ .../Authentication/Domain/UserSessionTests.cs | 174 +++++------ .../Sessions/SessionTrackerTests.cs | 119 -------- src/frontend-vue/src/models/ScheduledJob.ts | 2 + src/frontend-vue/src/models/application.ts | 6 + src/frontend-vue/src/models/oauth.ts | 12 + src/frontend-vue/src/models/realmSettings.ts | 26 ++ src/frontend-vue/src/models/session.ts | 17 ++ .../src/views/admin/RealmSettingsView.vue | 128 +++++++- .../views/admin/apps/AppSettingsSections.vue | 33 ++- .../src/views/admin/oauth/ClientDetails.vue | 32 +- .../src/views/dashboard/DashboardView.vue | 28 +- .../src/views/profile/ProfileView.vue | 125 +++++--- 96 files changed, 3290 insertions(+), 969 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs create mode 100644 src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs create mode 100644 src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs create mode 100644 src/dotnet/Modgud.Authentication/Domain/ClientSession.cs create mode 100644 src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs create mode 100644 src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs create mode 100644 src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs create mode 100644 src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs create mode 100644 src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs delete mode 100644 src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs create mode 100644 src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs create mode 100644 src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs create mode 100644 src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs delete mode 100644 src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs diff --git a/docs/admin/applications.md b/docs/admin/applications.md index 96a93e28..f139a0b6 100644 --- a/docs/admin/applications.md +++ b/docs/admin/applications.md @@ -96,7 +96,7 @@ effectively **rename** one — is to clone it. In the list, right-click a row - **Display name, description and the whole permission catalog** are copied. The catalog entries are copied as *new* entries (fresh ids), so the source app's role grants and resource-server subsets are left untouched. -- **Settings** are copied too — branding, registration, native-grant / DCR / CIMD +- **Settings** are copied too — branding, registration, client-session, native-grant / DCR / CIMD overrides — **except the Origin subdomain**, which is globally unique and would collide. Set a new subdomain on the copy if it needs one. @@ -140,6 +140,7 @@ re-inherits the realm. | **Email branding** | The product name used in this App's outbound emails (OTP, magic link, ...) instead of the realm default. | | **Self-registration** | Per-app override of the realm self-registration policy (allowed email domains, admin approval, default groups, ToS/privacy URLs) plus the **posture** (see below). Captcha stays realm-level. | | **Registration fields** | Per-app override of which identity fields (username / first / last name) are required when an account is created — each one inheriting the realm by default. See [Registration fields](#registration-fields) below. | +| **Client sessions** | Idle and absolute lifetime defaults for refresh-token-backed native/OAuth sessions belonging to this App. Each field inherits the realm unless overridden; an individual OAuth client can override the App again. | | **Native grants** | Per-app toggle + token lifetimes for the cookieless [native passwordless grants](../integrate/native-apps). | | **DCR** | Per-app override of [Dynamic Client Registration](./dynamic-client-registration) (enable, token lifetimes, rate limits, reserved-name blocklist). | | **CIMD** | Per-app override of [Client-ID Metadata Documents](./client-id-metadata-documents) (enable, token lifetimes). | diff --git a/docs/admin/oauth-clients.md b/docs/admin/oauth-clients.md index 7bb0f3d5..59a9b302 100644 --- a/docs/admin/oauth-clients.md +++ b/docs/admin/oauth-clients.md @@ -127,7 +127,9 @@ To get machine-to-machine tokens, create a Service Account; it provisions the co ### Lifetimes -The **Token Lifetimes** tab is edit-only (it appears once a client exists, not on the create form). Each field is **entered in seconds**; leaving it empty falls back to the IdP default. The defaults are: +The **Lifetimes** tab is available during create and edit. Each field is +**entered in seconds**. Empty token fields use the IdP default; empty +client-session fields inherit from the linked Application and then the Realm. | Field | Default | In seconds | | --- | --- | --- | @@ -135,9 +137,17 @@ The **Token Lifetimes** tab is edit-only (it appears once a client exists, not o | **Authorization Code Lifetime** | 5 min | `300` | | **Identity Token Lifetime** | OpenIddict default (no Modgud override) | — | | **Sliding Refresh Token Lifetime** | OpenIddict default (no Modgud override) | — | +| **Client Session Idle Lifetime** | App/Realm policy | — | +| **Client Session Absolute Lifetime** | App/Realm policy | — | Access-token, authorization-code and refresh-token defaults are set globally on the IdP (`AccessTokenLifetimeMinutes`, `AuthorizationCodeLifetimeMinutes`, `RefreshTokenLifetimeDays`). The identity-token and sliding-refresh fields have no Modgud-level default — leave them blank unless you have a specific reason to override OpenIddict's built-in value. +Client-session lifetimes control how long refresh-token-backed user sessions +may continue. Idle lifetime slides on successful refresh; absolute lifetime +never slides. Both accept 1–3650 days (`86400`–`315360000` seconds), and the +absolute value must not be shorter than idle. These do not lengthen access +tokens. + ## Editing / regenerating Open a client by double-click. Most fields can be edited live; **Client ID** is immutable after creation. diff --git a/docs/admin/realm-settings.md b/docs/admin/realm-settings.md index e9d589b4..d76c613f 100644 --- a/docs/admin/realm-settings.md +++ b/docs/admin/realm-settings.md @@ -8,7 +8,7 @@ ::: ::: tip These are the realm defaults — Applications can override them -The Self-Registration, Registration-Fields, DCR, CIMD and Native Passwordless +The Self-Registration, Registration-Fields, Client Sessions, DCR, CIMD and Native Passwordless Grants policies (and branding / email branding) set here are the **realm defaults**. An individual [Application](./applications#application-settings) can override a slice of them per-app (sparse, field by field — anything it @@ -23,6 +23,7 @@ The page currently has these tabs: - [Self-Registration](#self-registration) — public sign-up policy - [Registration Fields](#registration-fields) — which identity fields are required when an account is created +- [Sessions](#sessions) — browser/SSO policy and the native/OAuth client-session default - [Dynamic Client Registration](#dynamic-client-registration) — anonymous OAuth-client registration policy (linked detail page: [Dynamic Client Registration](./dynamic-client-registration)) @@ -171,6 +172,27 @@ Off by default. See the full feature page for when to enable it, what gets accep → **[Dynamic Client Registration](./dynamic-client-registration)** (full feature page) +## Sessions + +Browser and native clients deliberately use different policies: + +| Policy | Default | Meaning | +| --- | --- | --- | +| Browser idle lifetime | 30 days | Sliding inactivity window for the shared realm SSO cookie | +| Browser absolute lifetime | 180 days | Hard limit from interactive sign-in; activity never extends it | +| Allow remember me | on | Whether a caller may request a browser-persistent cookie | +| Client-session idle lifetime | 30 days | Sliding window renewed when a native/OAuth app uses its refresh token | +| Client-session absolute lifetime | 365 days | Hard limit before the app must perform a new user sign-in | + +Client-session values support 1–3650 days. Ten years is therefore valid for +low-risk consumer apps where forced periodic login would be disruptive. +Access tokens stay short-lived and independent of this setting. + +Resolution order is **OAuth client → Application → Realm**. Empty App/client +fields inherit the next level. A client linked to several Applications uses +the strictest participating App policy unless the client has an explicit +override. + ## Rate Limits Per-IP request ceilings for this realm's auth endpoints. Each policy is a **max requests / window (minutes)** pair, partitioned by source IP and applied **per realm**. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling **without a modgud code change + redeploy**, and so a hardened realm can tighten one. diff --git a/docs/admin/scheduled-jobs.md b/docs/admin/scheduled-jobs.md index a1fde5fd..a6dfa2cb 100644 --- a/docs/admin/scheduled-jobs.md +++ b/docs/admin/scheduled-jobs.md @@ -5,7 +5,7 @@ description: Tenant-admin surface for the realm's background scheduled jobs — # Scheduled Jobs -**Scheduled Jobs** are the realm's recurring background tasks — garbage collection, retention sweeps, periodic housekeeping. Each job ships with a sensible default schedule baked into the build; admins can override the cron expression, tweak per-job parameters, disable runs, trigger an out-of-band run, or read the last 50 executions per job — all from one page. +**Scheduled Jobs** are the realm's recurring background tasks — garbage collection, retention sweeps, periodic housekeeping. Each job ships with a sensible default schedule baked into the build; admins can override the cron expression, tweak per-job parameters, disable scheduled runs (manual runs remain available), trigger an out-of-band run, or read the last 50 executions per job — all from one page. ## Surface @@ -17,21 +17,31 @@ description: Tenant-admin surface for the realm's background scheduled jobs — The `realm:admin` role bypasses both; granular delegation works by handing out `scheduled-job:read` and/or `scheduled-job:write` from the modgud App catalog. ::: info Per-tenant -Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **calling tenant's** Marten DB. Each realm sees only its own runs and configures its own retention. +Every realm job has its own Quartz job + trigger. Run history (`JobRunHistoryEntry`) and per-job overrides (`JobConfig`) live in the **owning realm's** Marten DB. Changing or manually starting a job affects that realm only. ::: ## Registered jobs -Six jobs ship with Modgud today. Most of them iterate every active realm internally — you see one row per job, not one row per (job, realm). The exception is `security-audit-prune`, which operates on a single cross-realm store rather than per realm. +Eight job definitions ship with Modgud today: + +- Six are **realm jobs**. Each active realm gets an independent Quartz job and trigger, so one customer can run at 18:00, another at 21:00, and another can disable its cron and run manually. +- Two are **system jobs**: `system-job-run-history-retention` and `security-audit-prune`. Each exists exactly once because it operates on a deployment-wide store, and is visible/configurable only in the realm that currently holds the Control-Plane role. + +The Control-Plane realm is still a realm, so it also owns its own copies of all five realm jobs. + +System-job configuration and history live in the non-tenanted global store, +not in the Control-Plane realm's database. Transferring the Control-Plane role +therefore moves visibility and authority, but not the system job's data or +schedule. ### `inbox-retention` — Inbox Retention -Applies the per-kind inbox retention policy across every active realm. +Applies this realm's per-kind inbox retention policy. - **Default cron:** `0 0 3 * * ?` (03:00 UTC daily) - **Parameters:** none — retention rules are configured separately under [Inbox Settings](/platform/inbox). -- **What it does:** loads each realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, reports per-reason counts in the run summary. -- **On failure:** an `inbox-retention failed for realm ` entry is logged and an inbox notification fires (see [Failure notification](#failure-notification)). +- **What it does:** loads the owning realm's `InboxRetentionSettings` doc, dismisses or hard-deletes items per the configured policy, and reports per-reason counts in the run summary. +- **On failure:** the failure is written to that realm's history and an inbox notification fires there (see [Failure notification](#failure-notification)). ### `job-run-history-retention` — Job-Run-History Retention @@ -41,7 +51,7 @@ Trims the per-tenant `JobRunHistoryEntry` document table so it doesn't grow unbo - **Parameters:** - **Max. age in days** — runs older than this are deleted. Default `30`. Leave blank to disable the age sweep. - **Max. entries per job** — keep only the N newest entries per job key. Default unlimited. -- **What it does:** two independent passes per realm (age cutoff + per-key count cap), summed and reported. +- **What it does:** two independent passes in this realm (age cutoff + per-key count cap), summed and reported. - **On failure:** logged + inbox-notified. ::: tip Two independent caps @@ -54,7 +64,7 @@ Soft-deletes [Dynamic Client Registration](./dynamic-client-registration) client - **Default cron:** `0 0 4 * * ?` (04:00 UTC daily — after the two retention jobs) - **Parameters:** none — TTL lives on [Realm Settings → Dynamic Client Registration](./realm-settings#dynamic-client-registration) (`GcTtlDays`, default 90). -- **What it does:** for every realm with DCR enabled, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. Realms with DCR disabled are skipped after a single indexed lookup. +- **What it does:** when DCR is enabled in this realm, finds DCR-registered clients whose last-used timestamp is older than `now − GcTtlDays` and soft-deletes them via the OAuth application aggregate. A realm with DCR disabled is skipped after a single indexed lookup. - **On failure:** logged + inbox-notified. Soft delete means client_id history stays intact for forensics. ### `signing-key-janitor` — Signing Key Janitor @@ -63,27 +73,57 @@ Hard-deletes per-realm OAuth/OIDC signing keys whose rotation overlap window has - **Default cron:** `0 0 5 * * ?` (05:00 UTC daily — after the GC + retention jobs) - **Parameters:** none — the overlap window is a fixed 30 days. -- **What it does:** for every realm (including deactivated ones, whose retired keys still hold private signing material), deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. Realms with nothing expired finish after a single indexed lookup. See [Realm Settings → Signing Keys](./realm-settings#signing-keys) for the rotation that produces these retired keys. +- **What it does:** in its owning realm, deletes signing keys where `RetiredAt + 30 days < now`. Active keys and keys still inside their overlap window are left untouched. This is the one realm job whose trigger remains scheduled while a realm is deactivated, because soft-delete retains that realm's database and private key material. See [Realm Settings → Signing Keys](./realm-settings#signing-keys) for the rotation that produces these retired keys. - **On failure:** logged + inbox-notified. ### `account-lifecycle-sweep` — Account Lifecycle Sweep -Drives the account-deletion deadlines across every active realm: sends "about to be deleted" reminders, erases self-service deletion requests whose grace period has passed, and auto-purges admin recycle-bin users past their retention deadline (when auto-purge is enabled for the realm). Also prunes used/expired registration invite codes as a hygiene side effect. +Drives this realm's account-deletion deadlines: sends "about to be deleted" reminders, erases self-service deletion requests whose grace period has passed, and auto-purges admin recycle-bin users past their retention deadline (when auto-purge is enabled for the realm). Also prunes used/expired registration invite codes as a hygiene side effect. - **Default cron:** `0 30 3 * * ?` (03:30 UTC daily) - **Parameters:** none — deadlines and lead times come from [Realm Settings → Account Deletion](./realm-settings#account-deletion). -- **What it does:** for each realm, runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. -- **On failure:** logged per realm; the sweep continues with the remaining realms. +- **What it does:** runs the self-service reminder/erasure sweep, the admin recycle-bin auto-purge sweep, and the invite-code prune in the owning realm, then reports counts for each. See [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) for the lifecycle this job enforces. +- **On failure:** that realm's run fails and is written to its own history; no other realm's run is affected. + +### `session-prune` — Session Prune + +Removes expired browser/SSO and native OAuth client-session documents from +this realm. + +- **Default cron:** `0 15 4 * * ?` (04:15 UTC daily) +- **Parameters:** none — expiry is determined from each session's idle and + absolute lifetime. +- **What it does:** deletes `UserSession` and `ClientSession` rows whose idle + or absolute expiry has passed. Runtime cookie and refresh-token validation + already rejects an expired row, so pruning is storage hygiene rather than + the enforcement boundary. +- **On failure:** that realm's run fails and is written to its own history; no + other realm's run is affected. ### `security-audit-prune` — Security Audit Prune Hard-deletes security/ops audit entries older than a fixed 7-day retention window. +This is a deployment-wide **system job**: it appears only in the current Control-Plane realm and has only one Quartz trigger. + - **Default cron:** `0 0 2 * * ?` (02:00 UTC daily) - **Parameters:** none — the 7-day retention is fixed and not configurable per realm. - **What it does:** deletes entries older than the retention window from the single cross-realm audit store in one indexed delete — there's no per-realm iteration for this job. - **On failure:** logged + inbox-notified. +### `system-job-run-history-retention` — System Job-Run-History Retention + +Trims only the execution history of deployment-wide system jobs in the non-tenanted global store. + +This is itself a deployment-wide **system job**: it appears only in the current Control-Plane realm and has only one Quartz trigger. It is deliberately separate from `job-run-history-retention`, because a realm-owned job must never read or mutate platform metadata. + +- **Default cron:** `0 45 3 * * ?` (03:45 UTC daily) +- **Parameters:** + - **Max. age in days** — runs older than this are deleted. Default `30`. Leave blank to disable the age sweep. + - **Max. entries per job** — keep only the N newest entries per system-job key. Default unlimited. +- **What it does:** applies the same two independent retention caps as the realm job, but exclusively inside the global store. +- **On failure:** logged + inbox-notified through the current Control-Plane realm. + ## Job-detail modal Double-click any row (or open `/admin/scheduled-jobs#`) to get a three-tab modal. @@ -91,7 +131,7 @@ Double-click any row (or open `/admin/scheduled-jobs#`) to get a three- | Tab | What it shows | | --- | --- | | **Schedule** | Cron expression input (placeholder shows the registration default), enabled toggle, **Run now** button, and the computed **Next run** timestamp. | -| **Configuration** | One field per `JobParameterField` declared by the job, grouped by `Section` when set. Empty value = fall back to the schema's `Default`. Tab is hidden for jobs with no tunable parameters — currently every job except `job-run-history-retention`. | +| **Configuration** | One field per `JobParameterField` declared by the job, grouped by `Section` when set. Empty value = fall back to the schema's `Default`. Tab is hidden for jobs with no tunable parameters — currently every job except the realm and system job-history-retention jobs. | | **History** | Last 50 runs, newest first. Success runs show duration + optional one-line summary. Failed runs show the first-line error message and an expandable stack trace. Manual triggers carry a `manual` tag. | The modal's footer **Save** button persists Schedule + Configuration in one shot; the trigger button on the Schedule tab is independent. @@ -107,7 +147,7 @@ The scheduled cron is unaffected — the job's next regular run still fires per ## Cron overrides -The cron field on the Schedule tab is a **Quartz 7-field expression** (sec min hour day-of-month month day-of-week year). When the field is **empty** the job uses the registration default; when set, the override is persisted in a per-tenant `JobConfig` Marten document and applied to the live scheduler immediately. +The cron field on the Schedule tab is a **Quartz 7-field expression** (sec min hour day-of-month month day-of-week year). When the field is **empty** the job uses the registration default; when set, the override is persisted and applied to the live scheduler immediately. Realm-job overrides live in that realm's Marten DB; system-job overrides live only in the non-tenanted global store. The endpoint validates the expression server-side (`CronExpression.IsValidExpression`) and returns `400` with a clear error if it parses wrong — you won't see a runtime scheduler failure later. diff --git a/docs/contribute/testing/automated-tests.md b/docs/contribute/testing/automated-tests.md index 208f7da6..d71217c9 100644 --- a/docs/contribute/testing/automated-tests.md +++ b/docs/contribute/testing/automated-tests.md @@ -83,10 +83,10 @@ dotnet test | Area | File(s) | Tests | What's pinned | |---|---|---:|---| -| Domain types | `Authentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ApplicationUser}Tests.cs` | 51 | OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, session expiry, ApplicationUser default state | +| Domain types | `Authentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ClientSession, ApplicationUser}Tests.cs` | — | OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, browser/native session expiry, ApplicationUser default state | | Extensions | `Authentication/ExtensionMethods/{HttpContextExtensions, HttpRequestExtensions, ErrorOrExtensions}Tests.cs` | 25 | tenant accessor on HttpContext, source-IP resolution incl. the X-Forwarded-For pinning bug, ErrorOr → ProblemDetails mapping | | TwoFactorEnforcementMiddleware | `Authentication/Account/TwoFactorEnforcementMiddlewareTests.cs` | 23 | whitelist paths, federated-MFA AMR detection, early-exit branches; DB branches unit-untested by design | -| Sessions / SessionTracker | `Authentication/Sessions/SessionTrackerTests.cs` | 5 | best-effort tracking, swallows failures from `ISessionService` | +| Session policy + mapping | `Applications/EffectiveSettingsTests.cs`, `Application/OAuthAdminMappingTests.cs` | — | realm/application/client lifetime precedence, bounds, and API mapping | | Device info parsing | `Sessions/DeviceInfoServiceTests.cs` | 8 | Wangkanai.Detection mapping pins driven by a fake `IDetectionService`: browser/platform/device → DeviceInfo, "Others" collapse to "Unknown", version-zero collapse to null, defensive throw-swallow. Mac-Safari-as-Mobile pin gone (fix landed with the swap) | | EmailOtpConfiguration | `Authentication/Identity/EmailOtpConfigurationTests.cs` | 2 | default values | | TwoFactorHelper (extracted) | `Authentication/Account/Services/TwoFactorHelperTests.cs` | 10 | `BuildMethodsList` order/conditions (TOTP/email-with-address-required/passkey count), `TryExpireSetupGrace` exempt-bypass + DueAt overwrite | diff --git a/docs/end-user/profile.md b/docs/end-user/profile.md index 4899e71a..badb4b23 100644 --- a/docs/end-user/profile.md +++ b/docs/end-user/profile.md @@ -36,7 +36,12 @@ Sign-in methods and recovery state: ### Sessions -A list of your active sessions across all devices, with: +Two separate lists: + +- **Browser and SSO sessions** backed by the Modgud application cookie +- **Signed-in apps and devices** backed by OAuth refresh tokens, such as an iOS app + +Both show: - Device + browser (best-effort detection) - IP address @@ -44,8 +49,10 @@ A list of your active sessions across all devices, with: Actions: -- **End this session** on a single one -- **End all other sessions** — keeps the current one, signs you out everywhere else. Useful if you suspect somebody else has your credentials. +- **End this session/app** on a single entry. The current browser uses normal + **Sign out** instead of targeted deletion. +- **Sign out everywhere** — ends the current browser, every other browser and + every native/OAuth client session. Every device must authenticate again. ### Privacy diff --git a/docs/integrate/cookies-and-sessions.md b/docs/integrate/cookies-and-sessions.md index 692269d8..aa8d675a 100644 --- a/docs/integrate/cookies-and-sessions.md +++ b/docs/integrate/cookies-and-sessions.md @@ -34,14 +34,14 @@ Configured in `Program.cs`: | `HttpOnly` | `true` | XSS mitigation — JS can't read the cookie | | `SecurePolicy` | `SameAsRequest` | Cookie is marked `Secure` when the request itself is HTTPS (reflecting the real scheme behind a reverse proxy), so it's HTTPS-only in prod while still working over the plain-HTTP Vite dev proxy | | `SameSite` | `Lax` | Required for cross-site OIDC redirect-back navigations | -| `ExpireTimeSpan` | 30 days | Max lifetime of persistent cookies | -| `SlidingExpiration` | `true` | Refresh on active use | +| `ExpireTimeSpan` | 30 days | Framework fallback; the realm's browser-session policy sets the effective ticket expiry | +| `SlidingExpiration` | `true` | Refresh on active use, capped by the authoritative absolute lifetime | ## Cookies in detail | Cookie | SameSite | Purpose | Lifetime | |---|---|---|---| -| `Modgud.Auth` | `Lax` | Main session (app cookie) | 30 days (or session-only with `RememberMe=false`) | +| `Modgud.Auth` | `Lax` | Main browser/SSO session | Realm policy: 30-day idle / 180-day absolute by default; session-only when not persistent | | `Modgud.2FA` | `Strict` | UserId holder between password step and 2FA step | 5 min | | `Modgud.2FA.Remember` | `Strict` | "Remember this browser, skip 2FA" — Identity.TwoFactorRememberMe scheme | Identity default (30 days) | | `Modgud.External` | `Lax` | OIDC callback holder | 10 min | @@ -92,38 +92,57 @@ style domains. ## Session tracking -In parallel with the auth cookie, modgud maintains a `UserSession` -Marten document per active login. This enables session-management -features (list sessions, revoke individually, log out everywhere) that -a cookie alone can't provide. +The auth cookie carries a signed `modgud.session_id` claim bound to one +authoritative, realm-local `UserSession` document. Every authenticated +request verifies that the row still exists, belongs to the cookie subject +and has not expired. Deleting it therefore rejects the cookie on its next +request; it is not merely an activity log. -### Session cookie +### Browser-session binding -The `Modgud.Session` cookie (HttpOnly, Secure in prod) correlates the -browser with the `UserSession` document. On logout, the document is -deleted and the cookie is cleared. +The browser-session ID lives inside the encrypted `Modgud.Auth` ticket. +`Modgud.Session` is unrelated ASP.NET session state used for short-lived +passkey ceremony data. On normal logout, only the current `UserSession` +row is deleted and the auth cookie is cleared. ### UserSession document | Field | Source | Purpose | |---|---|---| | `UserId` | Auth system | Link | -| `SessionId` | Random GUID | Correlation with cookie | +| `Id` | UUIDv7/GUID | Correlation claim inside `Modgud.Auth` | | `IpAddress` | `HttpContext.Connection.RemoteIpAddress` (proxy-aware via `ForwardedHeaders`) | Audit | | `Browser`, `BrowserVersion` | UAParser | UI display | | `OperatingSystem`, `OsVersion` | UAParser | UI display | | `DeviceType` | UAParser | Desktop/Mobile/Tablet | -| `CreatedAt`, `LastActiveAt`, `ExpiresAt` | UTC | TTL + UI | +| `CreatedAt`, `LastActiveAt`, `ExpiresAt`, `AbsoluteExpiresAt` | UTC | Sliding idle window, hard limit and UI | -`SessionTracker` updates `LastActiveAt` on every authenticated request, -throttled (e.g. at most once per minute per session). +Validation updates `LastActiveAt` and the idle expiry at most once every +five minutes. Activity can never extend `AbsoluteExpiresAt`. Open SignalR +connections are bound to the same session and are aborted on targeted +revocation on the current node; hub invocations also revalidate the row. + +### Native/OAuth client sessions + +Native apps do not use the browser cookie. A refresh-token-capable login +(`offline_access`) creates a separate `ClientSession`, binds its ID into +the protected refresh token and roots that device's token family in a +unique OpenIddict authorization. Each refresh verifies and touches this +row. Revoking the row revokes exactly that device's tokens and +authorization. + +Policy resolution is OAuth client → Application → Realm. Defaults are +30 days idle and 365 days absolute; values up to 3650 days are supported. +Access-token lifetime remains independent and short. ### Self-service endpoints ```http GET /api/auth/sessions DELETE /api/auth/sessions/{id} -DELETE /api/auth/sessions # all except current +DELETE /api/auth/sessions/client/{id} +DELETE /api/auth/sessions/others # browser sessions except current +DELETE /api/auth/sessions # current + all browser/client sessions ``` ### Admin variants @@ -140,10 +159,10 @@ security-relevant events (password change, 2FA toggle) the stamp is invalidated; on the next cookie validation the cookie is rejected and the user is logged out. -Modgud uses that plus the `UserSession` documents: -"Log out everywhere" clears all `UserSession`s + invalidates the -security stamp → all of the user's cookies are rejected on the next -validation. +Modgud uses that together with both session document types. “Sign out +everywhere” clears all `UserSession` and `ClientSession` rows, revokes +OAuth tokens, invalidates the security stamp and clears the acting +cookie. Every browser and native app must authenticate again. ## Security summary @@ -153,5 +172,5 @@ validation. | Man-in-the-middle | `Secure` (prod) | | CSRF | `SameSite=Lax` on the main cookie + `Strict` on 2FA/Session step cookies + `CsrfDefenseMiddleware` on mutating endpoints | | Cross-realm leakage | Realm domain → own cookie domain | -| Forced logout | Security stamp + delete UserSession document | +| Forced logout | Per-request authoritative browser-session check + security stamp + OAuth client-session/token revocation | | Account lockout | 5 failed logins → 1 min lockout (DoS limit) | diff --git a/docs/operate/database.md b/docs/operate/database.md index 44780b8a..c310b6b3 100644 --- a/docs/operate/database.md +++ b/docs/operate/database.md @@ -220,7 +220,8 @@ Enums are stored as strings (readable in the DB inspector). | `mt_doc_realmsettings` | Realm-admin-owned config | | `mt_doc_applicationsettings` | Per-App config overrides | | `mt_doc_auth_audit_view` | Per-realm tenant audit feed (`AuthAuditView` projection — metadata only) | -| `mt_doc_usersession` | Active sessions | +| `mt_doc_usersession` | Authoritative browser/SSO sessions | +| `mt_doc_clientsession` | Authoritative native/OAuth client sessions and refresh-token-family binding | In the master DB additionally: diff --git a/docs/platform/settings.md b/docs/platform/settings.md index e15b022c..817a1f40 100644 --- a/docs/platform/settings.md +++ b/docs/platform/settings.md @@ -66,15 +66,16 @@ Configured as `TwoFactorGracePeriodDays` in deployment config — not currently editable from the admin UI. The shipping default is 14 days. -## Sign-in cookie lifetime +## Sign-in session lifetime -The auth cookie's `ExpireTimeSpan` is 30 days with sliding expiration. -"Remember me" controls whether the cookie is persistent at all — -without it, the cookie is session-only and dies with the browser tab. +Browser/SSO lifetimes are realm-owned and editable under +Administration → **Realm Settings → Sessions**. The defaults are a +30-day sliding idle window and a 180-day absolute limit. The same page +controls whether persistent “remember me” cookies are allowed. -These values currently come from deployment config (not the admin UI) -— see [Authentication cookies](../integrate/cookies-and-sessions) for the full -cookie inventory. +Native/OAuth client sessions have a separate realm default and can be +overridden per Application and per OAuth client. See +[Authentication cookies and sessions](../integrate/cookies-and-sessions). ## SMTP diff --git a/docs/reference/auth-api.md b/docs/reference/auth-api.md index 6f1bb53f..81edc4a1 100644 --- a/docs/reference/auth-api.md +++ b/docs/reference/auth-api.md @@ -128,9 +128,11 @@ These live under `/api/auth/...`, not `/api/account/...`. | Method | Path | Description | |---|---|---| -| `GET` | `/api/auth/sessions` | Active sessions | -| `DELETE` | `/api/auth/sessions/{id}` | Revoke a session | -| `DELETE` | `/api/auth/sessions` | Revoke all sessions except current ("logout everywhere") | +| `GET` | `/api/auth/sessions` | Browser sessions plus native/OAuth client sessions | +| `DELETE` | `/api/auth/sessions/{id}` | Revoke another browser session | +| `DELETE` | `/api/auth/sessions/client/{id}` | Revoke one native/OAuth client session and its token family | +| `DELETE` | `/api/auth/sessions/others` | Revoke every browser session except the current one | +| `DELETE` | `/api/auth/sessions` | Sign out everywhere, including the current browser and all OAuth client sessions | ## GDPR / privacy diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs index d66ff513..cfad310f 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/CocoarNativeGrantFlowTests.cs @@ -14,6 +14,7 @@ using Modgud.Application.Services; using Modgud.Authentication.Domain; using Modgud.Authentication.RealmSettings; +using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -44,7 +45,9 @@ public CocoarNativeGrantFlowTests(SharedPostgresFixture fixture) : base(fixture) public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() { await EnableNativeGrantsAsync(); - await SeedNativeClientAsync("native-otp-app"); + await SeedNativeClientAsync( + "native-otp-app", + clientSessionAbsoluteLifetime: 3650 * 24 * 60 * 60); var code = await RequestNativeOtpCodeAsync(); @@ -66,6 +69,7 @@ public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() Assert.False(string.IsNullOrEmpty(accessToken)); Assert.True(json.RootElement.TryGetProperty("refresh_token", out var rt) && !string.IsNullOrEmpty(rt.GetString()), "expected a (reference) refresh_token because offline_access was requested"); + var refreshToken = rt.GetString()!; // ADR-0010 — native access tokens are short-lived JWTs. var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken); @@ -78,6 +82,34 @@ public async Task Otp_Grant_MintsTokens_ShortLifetime_NoCookie() // Cookieless guarantee — the token endpoint must not set an auth cookie. Assert.False(response.Headers.Contains("Set-Cookie"), "the native grant must mint tokens without setting any cookie"); + + // The native login is represented independently from this browser's + // cookie session and can be revoked without touching other devices. + var sessionList = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, TestContext.Current.CancellationToken); + var nativeSession = Assert.Single( + sessionList!.ClientSessions, x => x.ClientId == "native-otp-app"); + Assert.InRange( + nativeSession.AbsoluteExpiresAt - nativeSession.CreatedAt, + TimeSpan.FromDays(3649), + TimeSpan.FromDays(3651)); + + var revoke = await Client.DeleteAsync( + $"/api/auth/sessions/client/{nativeSession.Id}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + + var rejectedRefresh = await PostTokenAsync(new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = "native-otp-app", + ["client_secret"] = "native-otp-app-secret", + ["refresh_token"] = refreshToken, + }); + Assert.Equal(HttpStatusCode.BadRequest, rejectedRefresh.StatusCode); + Assert.Contains( + "invalid_grant", + await rejectedRefresh.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } [Fact] @@ -539,10 +571,18 @@ private static byte[] Base32Decode(string input) // ── Seeding ──────────────────────────────────────────────────────────── - private Task SeedNativeClientAsync(string clientId) => - SeedClientAsync(clientId, [CocoarGrantTypes.Otp, CocoarGrantTypes.Magic, "refresh_token"]); - - private async Task SeedClientAsync(string clientId, List grantTypes) + private Task SeedNativeClientAsync( + string clientId, + int? clientSessionAbsoluteLifetime = null) => + SeedClientAsync( + clientId, + [CocoarGrantTypes.Otp, CocoarGrantTypes.Magic, "refresh_token"], + clientSessionAbsoluteLifetime); + + private async Task SeedClientAsync( + string clientId, + List grantTypes, + int? clientSessionAbsoluteLifetime = null) { var app = await CreateAppAsync($"{clientId}-catalog", clientId); @@ -562,6 +602,7 @@ private async Task SeedClientAsync(string clientId, List grantTypes) RequireConsent = false, AccessTokenType = AccessTokenType.Jwt, AppIds = [new ShortGuid(app.Id).ToString()], + ClientSessionAbsoluteLifetime = clientSessionAbsoluteLifetime, }; var result = await oauthAdmin.CreateClientAsync(dto, TestContext.Current.CancellationToken); if (result.IsError) diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index 2dd2a03e..4a20bafd 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -185,6 +185,12 @@ await GrantAsync(testUser.Id, roleAppSlug: "app-alpha", resourceType: "policy", scope: $"openid offline_access roles permissions {alphaScopeName}", resources: [alphaAudience]); var refreshToken = tokens.RootElement.GetProperty("refresh_token").GetString()!; + await using (var beforeReuse = GetTenantedDocumentSession()) + { + Assert.Single(await beforeReuse.Query() + .Where(x => x.UserId == testUser.Id && x.ClientId == clientId) + .ToListAsync(TestContext.Current.CancellationToken)); + } // redeem the refresh token → fresh reference access token var newAccessToken = await RedeemRefreshTokenAsync( @@ -266,6 +272,13 @@ await CreateOAuthClientAsync( $"Replayed refresh token should have been rejected: {replayBody}"); Assert.Contains("invalid_grant", replayBody); + await using (var afterReuse = GetTenantedDocumentSession()) + { + Assert.Empty(await afterReuse.Query() + .Where(x => x.UserId == testUser.Id && x.ClientId == clientId) + .ToListAsync(TestContext.Current.CancellationToken)); + } + // The reuse rejection emits a best-effort security event on the async // writer — poll briefly for it to land in the system-tenant streamless store. var recorded = await PollForSecurityAuditEntryAsync( diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs new file mode 100644 index 00000000..4f45f712 --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs @@ -0,0 +1,259 @@ +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Jobs; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; +using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Tests.ColdStart; + +/// +/// Pins the scheduling ownership contract: realm jobs have independent Quartz +/// identities/config/history, while system jobs exist once and are exposed only +/// through the current Control-Plane realm. +/// +public class ScheduledJobsTenancyTests(ColdStartFixture fixture) : ColdStartTestBase(fixture) +{ + [Fact] + public async Task Realm_schedules_are_independent_and_system_jobs_are_ControlPlane_only() + { + await using var host = await Fixture.CreateIsolatedHostAsync(); + var factory = host.Factory; + var ct = TestContext.Current.CancellationToken; + const string tenantSlug = "jobs-acme"; + + var realms = factory.Services.GetRequiredService(); + var created = await realms.CreateRealmAsync(new CreateRealmDto + { + Slug = tenantSlug, + DisplayName = "Jobs Acme", + Domains = [$"{tenantSlug}.localhost"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = "admin@jobs-acme.test", + }, + }, ct); + Assert.False(created.IsError); + + var scheduler = await factory.Services + .GetRequiredService() + .GetScheduler(ct); + + var controlPlaneRealmKey = new JobKey(DcrGcJob.Key, "realm:system"); + var tenantRealmKey = new JobKey(DcrGcJob.Key, $"realm:{tenantSlug}"); + var singletonSystemKey = new JobKey(SecurityAuditPruneJob.Key, "system"); + var singletonSystemRetentionKey = + new JobKey(SystemJobRunHistoryRetentionJob.Key, "system"); + + Assert.True(await scheduler.CheckExists(controlPlaneRealmKey, ct)); + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.True(await scheduler.CheckExists(singletonSystemKey, ct)); + Assert.True(await scheduler.CheckExists(singletonSystemRetentionKey, ct)); + Assert.False(await scheduler.CheckExists( + new JobKey(SecurityAuditPruneJob.Key, $"realm:{tenantSlug}"), ct)); + Assert.False(await scheduler.CheckExists( + new JobKey(SystemJobRunHistoryRetentionJob.Key, $"realm:{tenantSlug}"), ct)); + var globalStore = factory.Services.GetRequiredService(); + + await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.Contains(visible, + j => j.Key == SecurityAuditPruneJob.Key && j.Scope == nameof(JobScope.System)); + Assert.Contains(visible, + j => j.Key == SystemJobRunHistoryRetentionJob.Key + && j.Scope == nameof(JobScope.System)); + Assert.Contains(visible, + j => j.Key == DcrGcJob.Key && j.Scope == nameof(JobScope.Realm)); + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 21 * * ?", + Enabled = true, + }, ct); + await jobs.UpdateAsync(SecurityAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 17 1 * * ?", + Enabled = true, + }, ct); + await jobs.TriggerNowAsync(SecurityAuditPruneJob.Key, ct: ct); + }); + + await InTenantAsync(factory, tenantSlug, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.DoesNotContain(visible, j => j.Scope == nameof(JobScope.System)); + Assert.Null(await jobs.GetAsync(SecurityAuditPruneJob.Key, ct)); + Assert.Null(await jobs.GetAsync(SystemJobRunHistoryRetentionJob.Key, ct)); + + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 18 * * ?", + Enabled = true, + }, ct); + }); + + Assert.Equal( + "0 0 21 * * ?", + await GetCronAsync(scheduler, controlPlaneRealmKey, ct)); + Assert.Equal( + "0 0 18 * * ?", + await GetCronAsync(scheduler, tenantRealmKey, ct)); + + var systemRun = await WaitForGlobalManualRunAsync( + factory, SecurityAuditPruneJob.Key, ct); + Assert.NotNull(systemRun); + await using (var globalSession = globalStore.QuerySession()) + { + var systemConfig = await globalSession.LoadAsync( + SecurityAuditPruneJob.Key, ct); + Assert.Equal("0 17 1 * * ?", systemConfig?.CronOverride); + } + + await using (var tenantMetadataSession = factory.Services + .GetRequiredService() + .QuerySession(TenantConstants.SystemTenantId)) + { + Assert.Null(await tenantMetadataSession.LoadAsync( + SecurityAuditPruneJob.Key, ct)); + Assert.False(await tenantMetadataSession.Query() + .AnyAsync(h => h.JobKey == SecurityAuditPruneJob.Key, ct)); + } + + // Disabled means manual-only: the durable realm job remains, but its + // own trigger disappears and a manual run still writes tenant history. + await InTenantAsync(factory, tenantSlug, async jobs => + { + await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto + { + CronOverride = "0 0 18 * * ?", + Enabled = false, + }, ct); + await jobs.TriggerNowAsync(DcrGcJob.Key, Guid.NewGuid(), ct); + }); + + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.DoesNotContain( + await scheduler.GetTriggersOfJob(tenantRealmKey, ct), + trigger => trigger is ICronTrigger); + + var tenantRun = await WaitForManualRunAsync(factory, tenantSlug, DcrGcJob.Key, ct); + Assert.NotNull(tenantRun); + + await using var systemSession = factory.Services + .GetRequiredService() + .QuerySession(TenantConstants.SystemTenantId); + Assert.False(await systemSession.Query() + .AnyAsync(h => h.JobKey == DcrGcJob.Key && h.ManualTrigger, ct)); + + // Realm lifecycle reconciles the group immediately. Normal realm jobs + // stop while inactive; private-key hygiene deliberately remains. + var deactivated = await realms.UpdateRealmAsync( + tenantSlug, new UpdateRealmDto { IsActive = false }, ct); + Assert.False(deactivated.IsError); + Assert.False(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.True(await scheduler.CheckExists( + new JobKey(SigningKeyJanitorJob.Key, $"realm:{tenantSlug}"), ct)); + + var reactivated = await realms.UpdateRealmAsync( + tenantSlug, new UpdateRealmDto { IsActive = true }, ct); + Assert.False(reactivated.IsError); + Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); + Assert.DoesNotContain( + await scheduler.GetTriggersOfJob(tenantRealmKey, ct), + trigger => trigger is ICronTrigger); + + // Moving the Control-Plane role moves system-job visibility/context, + // while the reserved Quartz identity remains a single instance. + var transferred = await realms.TransferControlPlaneAsync(tenantSlug, ct); + Assert.False(transferred.IsError); + var systemDetail = await scheduler.GetJobDetail(singletonSystemKey, ct); + Assert.NotNull(systemDetail); + Assert.Equal( + tenantSlug, + systemDetail!.JobDataMap.GetString("__modgudTenantSlug")); + + await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => + { + Assert.DoesNotContain( + await jobs.GetAllAsync(ct), + j => j.Scope == nameof(JobScope.System)); + }); + await InTenantAsync(factory, tenantSlug, async jobs => + { + var visible = await jobs.GetAllAsync(ct); + Assert.Contains(visible, + j => j.Key == SecurityAuditPruneJob.Key + && j.Scope == nameof(JobScope.System) + && j.EffectiveCron == "0 17 1 * * ?"); + }); + } + + private static async Task InTenantAsync( + ColdStartWebApplicationFactory factory, + string slug, + Func action) + { + using var tenant = TenantContext.Enter(slug); + using var scope = factory.Services.CreateScope(); + await action(scope.ServiceProvider.GetRequiredService()); + } + + private static async Task GetCronAsync( + IScheduler scheduler, + JobKey jobKey, + CancellationToken ct) + { + var trigger = Assert.Single(await scheduler.GetTriggersOfJob(jobKey, ct)); + return Assert.IsAssignableFrom(trigger).CronExpressionString; + } + + private static async Task WaitForManualRunAsync( + ColdStartWebApplicationFactory factory, + string realmSlug, + string jobKey, + CancellationToken ct) + { + var store = factory.Services.GetRequiredService(); + for (var attempt = 0; attempt < 100; attempt++) + { + await using var session = store.QuerySession(realmSlug); + var entry = await session.Query() + .Where(h => h.JobKey == jobKey && h.ManualTrigger) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + if (entry is not null) + return entry; + + await Task.Delay(TimeSpan.FromMilliseconds(50), ct); + } + + return null; + } + + private static async Task WaitForGlobalManualRunAsync( + ColdStartWebApplicationFactory factory, + string jobKey, + CancellationToken ct) + { + var store = factory.Services.GetRequiredService(); + for (var attempt = 0; attempt < 100; attempt++) + { + await using var session = store.QuerySession(); + var entry = await session.Query() + .Where(h => h.JobKey == jobKey && h.ManualTrigger) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + if (entry is not null) + return entry; + + await Task.Delay(TimeSpan.FromMilliseconds(50), ct); + } + + return null; + } +} diff --git a/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs b/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs index 4fe5cd5f..736f3b06 100644 --- a/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Security/MfaTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Sessions; namespace Modgud.Api.Tests.Security; @@ -41,6 +42,24 @@ public async Task MfaSetup_ReturnsSharedKeyAndAuthenticatorUri() Assert.Contains("Modgud", uri); } + [Fact] + public async Task MfaSetup_RefreshSignIn_PreservesTheBrowserSessionId() + { + var ct = TestContext.Current.CancellationToken; + var before = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentBefore = Assert.Single(before!.Sessions, x => x.IsCurrent); + + var response = await Client.PostAsync("/api/account/mfa/setup", null, ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var after = await Client.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentAfter = Assert.Single(after!.Sessions, x => x.IsCurrent); + Assert.Equal(currentBefore.Id, currentAfter.Id); + Assert.Equal(before.Sessions.Count, after.Sessions.Count); + } + [Fact] public async Task MfaVerify_WithInvalidCode_ReturnsBadRequest() { diff --git a/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs b/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs index 8168e057..1a14a08a 100644 --- a/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs +++ b/src/dotnet/Modgud.Api.Tests/Security/SecurityAuditWave1Tests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; using Modgud.Authentication.Domain; +using Modgud.Authentication.Sessions; using BuildingBlocks.Helper; namespace Modgud.Api.Tests.Security; @@ -15,12 +16,8 @@ namespace Modgud.Api.Tests.Security; /// (OAuth tokens + device-session rows + auth cookies), not just rotate the stamp /// or delete tracking rows. /// -/// Note on what is asserted: -/// - #1 "revoke all" does NOT rotate the stamp today (only deletes rows), so the -/// OTHER device's cookie survives — asserted directly via a second cookie client. -/// - #2/#3 password reset ALREADY rotates the Identity stamp (so cookies die), but -/// leaves OAuth tokens AND device-session rows alive. We assert the device-session -/// rows are revoked (proves RevokeAllAccessAsync ran), which is RED before the fix. +/// "Sign out everywhere" includes the acting browser and every other browser or +/// native client session. Password resets use the same access-revocation path. /// [Collection(IntegrationTestCollection.Name)] public class SecurityAuditWave1Tests : IntegrationTestBase @@ -31,7 +28,7 @@ public SecurityAuditWave1Tests(SharedPostgresFixture fixture) : base(fixture) { // #1 — self-service "log out everywhere" must invalidate other devices' cookies. [Fact] - public async Task RevokeAllSessions_InvalidatesOtherDeviceCookie_KeepsActingSession() + public async Task RevokeAllSessions_InvalidatesEveryDeviceIncludingTheCaller() { var ct = TestContext.Current.CancellationToken; var deviceA = await CreateAuthenticatedClientAsync("tu", Password); @@ -39,21 +36,62 @@ public async Task RevokeAllSessions_InvalidatesOtherDeviceCookie_KeepsActingSess Assert.Equal(HttpStatusCode.OK, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); + var sessionsSeenByA = await deviceA.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var sessionsSeenByB = await deviceB.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var currentA = Assert.Single(sessionsSeenByA!.Sessions, x => x.IsCurrent); + var currentB = Assert.Single(sessionsSeenByB!.Sessions, x => x.IsCurrent); + Assert.NotEqual(currentA.Id, currentB.Id); + var revoke = await deviceA.DeleteAsync("/api/auth/sessions", ct); - Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + Assert.True( + revoke.StatusCode == HttpStatusCode.NoContent, + $"Expected 204, got {(int)revoke.StatusCode}: {await revoke.Content.ReadAsStringAsync(ct)}"); // Other device must now be rejected at the next SecurityStampValidator pass // (ValidationInterval=0 in the harness). RED today: revoke-all only deletes // tracking rows, never rotates the stamp, so device B keeps authenticating. Assert.Equal(HttpStatusCode.Unauthorized, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); - // Acting device survives (RefreshSignInAsync re-issues its cookie). + Assert.Equal(HttpStatusCode.Unauthorized, (await deviceA.GetAsync("/api/account/me", ct)).StatusCode); + Assert.Equal(0, await SessionCountAsync(DefaultUser!.Id, ct)); + } + + [Fact] + public async Task TargetedRevoke_InvalidatesOnlyTheSelectedBrowserSession() + { + var ct = TestContext.Current.CancellationToken; + var deviceA = await CreateAuthenticatedClientAsync("tu", Password); + var deviceB = await CreateAuthenticatedClientAsync("tu", Password); + var sessionsSeenByB = await deviceB.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var deviceBSession = Assert.Single(sessionsSeenByB!.Sessions, x => x.IsCurrent); + + var revoke = await deviceA.DeleteAsync( + $"/api/auth/sessions/{deviceBSession.Id}", ct); + + Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await deviceA.GetAsync("/api/account/me", ct)).StatusCode); + } - // Re-audit regression guard: revoke-all deletes EVERY session row including - // the acting device's; the acting session must be re-recorded so the user's - // own "active sessions" list isn't left empty while they're still signed in. - Assert.True(await SessionCountAsync(DefaultUser!.Id, ct) >= 1); + [Fact] + public async Task NormalLogout_RemovesOnlyTheActingBrowserSession() + { + var ct = TestContext.Current.CancellationToken; + var deviceA = await CreateAuthenticatedClientAsync("tu", Password); + var deviceB = await CreateAuthenticatedClientAsync("tu", Password); + var list = await deviceA.GetFromJsonAsync( + "/api/auth/sessions", JsonOptions, ct); + var deviceASession = Assert.Single(list!.Sessions, x => x.IsCurrent); + + var logout = await deviceA.PostAsync("/api/account/logout", null, ct); + + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + await using var read = GetTenantedDocumentSession(); + Assert.Null(await read.LoadAsync(Guid.Parse(deviceASession.Id), ct)); + Assert.Equal(HttpStatusCode.OK, (await deviceB.GetAsync("/api/account/me", ct)).StatusCode); } // #2 — admin password reset must revoke the target user's live access. @@ -70,7 +108,9 @@ public async Task AdminPasswordReset_RevokesTargetUserSessions() // Admin (default Client = realm admin) resets the target's password. var resp = await Client.PutAsJsonAsync( $"/api/user/{new ShortGuid(target.Id)}/password", new { Password = "NewPass4567!" }, ct); - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.True( + resp.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync(ct)}"); // RED today: admin reset rotates the stamp but never calls RevokeAllAccessAsync, // so the device-session rows survive (and so do OAuth tokens). @@ -97,7 +137,9 @@ public async Task SelfServicePasswordReset_RevokesUserSessions() var anon = Factory.CreateDefaultClient(new CookieContainerHandler()); var resp = await anon.PostAsJsonAsync("/api/account/reset-password", new { UserId = DefaultUser!.Id.ToString(), Token = token, NewPassword = "NewPass4567!" }, ct); - Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.True( + resp.StatusCode == HttpStatusCode.OK, + $"Expected 200, got {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync(ct)}"); // RED today: reset-password rotates the stamp but never calls RevokeAllAccessAsync. Assert.Equal(0, await SessionCountAsync(DefaultUser!.Id, ct)); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs index 9d2e454d..2b93d03e 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs @@ -1,9 +1,5 @@ -using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Modgud.Authentication.Gdpr; using Modgud.Authentication.SelfRegistration; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; using Quartz; @@ -22,18 +18,15 @@ namespace Modgud.Api.Features.Admin.Jobs; /// (only when the realm has AutoPurge enabled). /// /// -/// Mirrors 's multi-tenant shape: it reads the -/// realm list from the master DB, then runs the per-realm work inside each -/// realm's so the scoped IGdprService (and -/// its Marten session + RealmSettings) resolve against the right tenant DB — -/// there is no HttpContext in a scheduled job. +/// Quartz creates one instance per realm. The scheduler enters that +/// realm's before resolving this job, so all +/// constructor-injected services bind to exactly one tenant database. /// [DisallowConcurrentExecution] public class AccountLifecycleSweepJob( - IServiceScopeFactory scopeFactory, - IDocumentStore store, - ISecurityAuditLog securityAudit, - ILogger logger) : IJob + IGdprService gdpr, + IRegistrationInviteService inviteService, + ISecurityAuditLog securityAudit) : IJob { public const string Key = "account-lifecycle-sweep"; public const string Name = "Account Lifecycle Sweep"; @@ -48,56 +41,28 @@ public class AccountLifecycleSweepJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; - await using var masterSession = store.LightweightSession(TenantConstants.SystemTenantId); - var realms = await masterSession.Query() - .Where(r => r.IsActive) - .ToListAsync(ct); + var (reminded, erased) = await gdpr.RunSelfServiceSweepAsync(ct); + var purged = await gdpr.RunAdminRetentionPurgeAsync(ct); - int realmsTouched = 0, totalReminded = 0, totalErased = 0, totalPurged = 0, totalInviteCodesPruned = 0; - foreach (var realm in realms) - { - if (ct.IsCancellationRequested) break; - try - { - using var scope = scopeFactory.CreateScope(); - using (TenantContext.Enter(realm.Slug)) - { - // Resolve INSIDE the tenant context so the scoped GdprService's - // Marten session binds to this realm's DB. - var gdpr = scope.ServiceProvider.GetRequiredService(); - var (reminded, erased) = await gdpr.RunSelfServiceSweepAsync(ct); - var purged = await gdpr.RunAdminRetentionPurgeAsync(ct); + // ADR-0012 §8 — prune used/expired invite codes (hygiene only). + var inviteCodesPruned = await inviteService.PruneAsync(ct); - // ADR-0012 §8 — prune used/expired invite codes (hygiene only). - var inviteService = scope.ServiceProvider.GetRequiredService(); - var inviteCodesPruned = await inviteService.PruneAsync(ct); - - totalReminded += reminded; - totalErased += erased; - totalPurged += purged; - totalInviteCodesPruned += inviteCodesPruned; - if (reminded + erased + purged + inviteCodesPruned > 0) - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.AccountLifecycleSwept, - Level = "Info", - Realm = realm.Slug, - Status = "swept", - Reason = $"reminded={reminded} selfErased={erased} autoPurged={purged} inviteCodesPruned={inviteCodesPruned}", - Message = $"Account-lifecycle sweep — Realm={realm.Slug} Reminded={reminded} SelfErased={erased} AutoPurged={purged} InviteCodesPruned={inviteCodesPruned}", - }); - } - realmsTouched++; - } - catch (Exception ex) + if (reminded + erased + purged + inviteCodesPruned > 0) + { + securityAudit.Record(new SecurityAuditRecord { - logger.LogError(ex, - "Account-lifecycle sweep failed for realm {Realm}", realm.Slug); - } + EventType = AuditEvents.AccountLifecycleSwept, + Level = "Info", + Realm = realmSlug, + Status = "swept", + Reason = $"reminded={reminded} selfErased={erased} autoPurged={purged} inviteCodesPruned={inviteCodesPruned}", + Message = $"Account-lifecycle sweep — Realm={realmSlug} Reminded={reminded} SelfErased={erased} AutoPurged={purged} InviteCodesPruned={inviteCodesPruned}", + }); } context.Result = - $"{realmsTouched} realm(s): {totalReminded} reminded, {totalErased} self-erased, {totalPurged} auto-purged, {totalInviteCodesPruned} invite-codes pruned"; + $"{reminded} reminded, {erased} self-erased, {purged} auto-purged, {inviteCodesPruned} invite-codes pruned"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs index d02579fa..36d4a6d4 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs @@ -1,13 +1,11 @@ using System.Text.Json; using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Quartz; using Modgud.Application.Dcr; using Modgud.Application.Scheduling; using Modgud.Domain.OAuth.Applications; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Features.Admin.Jobs; @@ -29,7 +27,7 @@ namespace Modgud.Api.Features.Admin.Jobs; /// [DisallowConcurrentExecution] public class DcrGcJob( - IServiceScopeFactory scopeFactory, + IDocumentSession session, ISecurityAuditLog securityAudit) : IJob { public const string Key = "dcr-gc"; @@ -45,41 +43,24 @@ public class DcrGcJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; + var swept = await SweepRealmAsync(session, realmSlug, ct); - using var rootScope = scopeFactory.CreateScope(); - var store = rootScope.ServiceProvider.GetRequiredService(); - - // Realms live in the master DB. The control-plane realm uses the - // "system" tenant; tenant realms use their slug. - await using var masterSession = store.LightweightSession("system"); - var realms = await masterSession.Query() - .Where(r => r.IsActive) - .ToListAsync(ct); - - int realmsTouched = 0; - int totalSwept = 0; - foreach (var realm in realms) + context.Result = swept switch { - if (ct.IsCancellationRequested) break; - var swept = await SweepRealmAsync(store, realm.Slug, ct); - if (swept >= 0) - { - realmsTouched++; - totalSwept += swept; - } - } - - context.Result = totalSwept == 0 - ? $"No DCR clients aged out ({realmsTouched} realm(s) checked)" - : $"Soft-deleted {totalSwept} DCR client(s) across {realmsTouched} realm(s)"; + < 0 => "Skipped because DCR is disabled", + 0 => "No DCR clients aged out", + _ => $"Soft-deleted {swept} DCR client(s)", + }; } /// Returns swept count, or -1 if the realm was skipped (DCR disabled). - private async Task SweepRealmAsync(IDocumentStore store, string tenantId, CancellationToken ct) + private async Task SweepRealmAsync( + IDocumentSession tenantSession, + string tenantId, + CancellationToken ct) { - await using var session = store.LightweightSession(tenantId); - - var settings = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct); + var settings = await tenantSession.LoadAsync(RealmSettingsDoc.SingletonId, ct); var dcr = settings?.Dcr; if (dcr is null || !dcr.Enabled) return -1; @@ -88,7 +69,7 @@ private async Task SweepRealmAsync(IDocumentStore store, string tenantId, C // match — pull the candidates and filter in memory. Set size is // bounded by the realm-rate-limit (default 100/d × TTL=90d = 9000 // max-ever), tiny enough for an in-memory pass). - var candidates = await session.Query() + var candidates = await tenantSession.Query() .Where(x => !x.IsDeleted) .ToListAsync(ct); @@ -102,11 +83,11 @@ private async Task SweepRealmAsync(IDocumentStore store, string tenantId, C var lastUsedAt = ParseTimestamp(state.Properties, OAuthApplicationPropertyKeys.DcrLastUsedAt); if (lastUsedAt is null || lastUsedAt > cutoff) continue; - var aggregate = await session.Events + var aggregate = await tenantSession.Events .AggregateStreamAsync(state.Id, token: ct); if (aggregate is null || aggregate.IsDeleted) continue; - session.Events.Append(state.Id, aggregate.Delete()); + tenantSession.Events.Append(state.Id, aggregate.Delete()); swept++; securityAudit.Record(new SecurityAuditRecord @@ -122,7 +103,7 @@ private async Task SweepRealmAsync(IDocumentStore store, string tenantId, C if (swept > 0) { - await session.SaveChangesAsync(ct); + await tenantSession.SaveChangesAsync(ct); } return swept; } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs index af570dc9..70bd103d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobRunHistoryRetentionJob.cs @@ -1,25 +1,20 @@ using System.Text.Json; using Marten; -using Microsoft.Extensions.DependencyInjection; using Quartz; using Modgud.Application.Scheduling; -using Modgud.Infrastructure.Persistence.Tenancy; -using Modgud.Infrastructure.Realms; using Modgud.Infrastructure.Scheduling; namespace Modgud.Api.Features.Admin.Jobs; /// -/// Trims the document table for every active -/// realm. Iterates tenants via ; each tenant gets -/// its own DI scope so the injected -/// opens its Marten session against the right tenant DB. Two independent caps — -/// both tunable in the admin UI without a code change. +/// Trims the owning realm's document table. +/// Quartz creates one instance per realm, with two independent caps tunable +/// from that realm's admin UI. /// [DisallowConcurrentExecution] public class JobRunHistoryRetentionJob( - IServiceScopeFactory scopeFactory, - IRealmCache realmCache) : IJob + IDocumentSession session, + IJobRunHistoryRetentionService retention) : IJob { public const string Key = "job-run-history-retention"; public const string Name = "Job-Run-History Retention"; @@ -54,46 +49,20 @@ public static IReadOnlyList GetParameterSchema() => public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - var realms = await realmCache.GetAllActiveAsync(); - - int totalByAge = 0; - int totalByCount = 0; - int tenantsProcessed = 0; - - foreach (var realm in realms) - { - try - { - using var scope = scopeFactory.CreateScope(); - using var _ = TenantContext.Enter(realm.Slug); - - var session = scope.ServiceProvider.GetRequiredService(); - var retention = scope.ServiceProvider.GetRequiredService(); - - var config = await BuildConfigAsync(session, ct); - var result = await retention.ExecuteAsync(config, ct); - - totalByAge += result.DeletedByAge; - totalByCount += result.DeletedByCount; - tenantsProcessed++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Serilog.Log.Error(ex, - "job-run-history-retention failed for realm {Slug}", - realm.Slug); - } - } - - var total = totalByAge + totalByCount; + var config = await BuildConfigAsync(session, Key, ct); + var result = await retention.ExecuteAsync(config, ct); + var total = result.DeletedByAge + result.DeletedByCount; context.Result = total == 0 - ? $"Nothing to delete ({tenantsProcessed} tenant(s) checked)" - : $"Deleted {total} entries across {tenantsProcessed} tenant(s) (age: {totalByAge}, count: {totalByCount})"; + ? "Nothing to delete" + : $"Deleted {total} entries (age: {result.DeletedByAge}, count: {result.DeletedByCount})"; } - private static async Task BuildConfigAsync(IDocumentSession session, CancellationToken ct) + internal static async Task BuildConfigAsync( + IQuerySession session, + string configKey, + CancellationToken ct) { - var cfg = await session.LoadAsync(Key, ct); + var cfg = await session.LoadAsync(configKey, ct); var raw = cfg?.Parameters ?? new Dictionary(); return new JobRunHistoryRetentionConfig( MaxAgeDays: ReadInt(raw, MaxAgeDaysKey) ?? DefaultMaxAgeDays, diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs index 0e1abaed..78029e9d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/JobsEndpoints.cs @@ -7,12 +7,12 @@ namespace Modgud.Api.Features.Admin.Jobs; /// -/// Admin surface for scheduled jobs. Per-tenant — JobConfig overrides + run -/// history are stored in the calling tenant's Marten session. Realm-admin -/// bypass (per Modgud's 3-tier permission model) lets any realm admin -/// drive the scheduler; granular delegation works via -/// scheduled-job:read + scheduled-job:write seeded in the -/// modgud App catalog. +/// Admin surface for scheduled jobs. Realm-job configuration and history are +/// stored in the calling realm's Marten session and address only that realm's +/// Quartz identities. Deployment-wide system jobs are additionally returned +/// only for the current Control-Plane realm. Realm-admin bypass and granular +/// scheduled-job:read/scheduled-job:write delegation apply within +/// that visibility boundary. /// public static class JobsEndpoints { @@ -36,7 +36,16 @@ public static WebApplication MapJobsEndpoints(this WebApplication app, string pa .RequiresPermission("scheduled-job:read"); group.MapGet("{key}/history", async (string key, IJobsService jobs, int take, CancellationToken ct) => - Results.Ok(await jobs.GetHistoryAsync(key, take == 0 ? 50 : take, ct))) + { + try + { + return Results.Ok(await jobs.GetHistoryAsync(key, take == 0 ? 50 : take, ct)); + } + catch (InvalidOperationException ex) + { + return Results.NotFound(new { error = ex.Message }); + } + }) .WithName("V2_AdminJobs_GetHistory") .RequiresPermission("scheduled-job:read"); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs new file mode 100644 index 00000000..03a3a3a2 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SessionPruneJob.cs @@ -0,0 +1,22 @@ +using Modgud.Authentication.Sessions; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +[DisallowConcurrentExecution] +public sealed class SessionPruneJob( + ISessionService browserSessions, + IClientSessionService clientSessions) : IJob +{ + public const string Key = "session-prune"; + public const string Name = "Session Retention"; + public const string Description = "Remove expired browser and native OAuth client/device sessions in this realm."; + public const string DefaultCron = "0 15 4 * * ?"; + + public async Task Execute(IJobExecutionContext context) + { + var browser = await browserSessions.PruneExpiredAsync(context.CancellationToken); + var clients = await clientSessions.PruneExpiredAsync(context.CancellationToken); + context.Result = $"Deleted {browser} browser sessions and {clients} client sessions"; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs index 3322a227..fbd6ef30 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs @@ -1,9 +1,6 @@ -using Marten; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Quartz; -using Modgud.Domain.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; namespace Modgud.Api.Features.Admin.Jobs; @@ -19,17 +16,15 @@ namespace Modgud.Api.Features.Admin.Jobs; /// a ValidUntil), and this janitor removes the now-dead row from the /// tenant DB so retired private material doesn't accumulate. /// -/// Per-realm and idempotent: a realm with no expired retired keys ends -/// after a single indexed query. Mirrors : realms live in -/// the master DB; the control-plane realm uses the "system" tenant, tenant -/// realms use their slug. +/// Per-realm and idempotent: Quartz creates one instance per realm, and a +/// realm with no expired retired keys ends after a single indexed query. Its +/// schedule deliberately remains active for deactivated realms because their +/// soft-deleted tenant databases still contain private key material. /// [DisallowConcurrentExecution] public class SigningKeyJanitorJob( - IServiceScopeFactory scopeFactory, IRealmKeyStore keyStore, - ISecurityAuditLog securityAudit, - ILogger logger) : IJob + ISecurityAuditLog securityAudit) : IJob { public const string Key = "signing-key-janitor"; public const string Name = "Signing Key Janitor"; @@ -44,55 +39,24 @@ public class SigningKeyJanitorJob( public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; + var realmSlug = TenantContext.Current; + var purged = await keyStore.PurgeExpiredRetiredKeysAsync(realmSlug, ct); - using var rootScope = scopeFactory.CreateScope(); - var store = rootScope.ServiceProvider.GetRequiredService(); - - // Realms live in the master DB. The control-plane realm uses the - // "system" tenant; tenant realms use their slug. NOTE: we deliberately - // do NOT filter on IsActive — a deactivated realm is a soft-delete that - // keeps its tenant DB, and its retired keys still hold private signing - // material that must not accumulate indefinitely. - await using var masterSession = store.LightweightSession("system"); - var realms = await masterSession.Query() - .ToListAsync(ct); - - int realmsTouched = 0; - int totalPurged = 0; - foreach (var realm in realms) + if (purged > 0) { - if (ct.IsCancellationRequested) break; - if (string.IsNullOrWhiteSpace(realm.Slug)) continue; - - try - { - var purged = await keyStore.PurgeExpiredRetiredKeysAsync(realm.Slug, ct); - if (purged > 0) - { - realmsTouched++; - totalPurged += purged; - // Realm-iterating job: bind the explicit iterated slug. - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.SigningKeyPurged, - Realm = realm.Slug, - Level = "Info", - Status = "purged", - Reason = $"purged {purged} expired retired key(s)", - Message = $"signing-key janitor purged {purged} expired retired key(s)", - }); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) + securityAudit.Record(new SecurityAuditRecord { - // One unreachable/broken tenant DB must not abort the whole sweep. - logger.LogWarning(ex, - "Signing-key janitor failed for realm {Realm} — skipping", realm.Slug); - } + EventType = AuditEvents.SigningKeyPurged, + Realm = realmSlug, + Level = "Info", + Status = "purged", + Reason = $"purged {purged} expired retired key(s)", + Message = $"signing-key janitor purged {purged} expired retired key(s)", + }); } - context.Result = totalPurged == 0 - ? $"No expired signing keys ({realms.Count} realm(s) checked)" - : $"Purged {totalPurged} expired signing key(s) across {realmsTouched} realm(s)"; + context.Result = purged == 0 + ? "No expired signing keys" + : $"Purged {purged} expired signing key(s)"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs new file mode 100644 index 00000000..deb0bbc1 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SystemJobRunHistoryRetentionJob.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +/// +/// Trims deployment-wide system-job history in the non-tenanted global store. +/// This is intentionally a separate system job: no realm-owned retention job +/// may read or mutate platform job metadata. +/// +[DisallowConcurrentExecution] +public sealed class SystemJobRunHistoryRetentionJob( + IGlobalStore globalStore, + ILogger logger) : IJob +{ + public const string Key = "system-job-run-history-retention"; + public const string Name = "System Job-Run-History Retention"; + public const string Description = + "Trims deployment-wide system-job run history in the non-tenanted global store."; + + /// 03:45 UTC daily, after realm history retention. + public const string DefaultCron = "0 45 3 * * ?"; + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var session = globalStore.LightweightSession(); + var config = await JobRunHistoryRetentionJob.BuildConfigAsync(session, Key, ct); + var result = await JobRunHistoryRetentionService.ExecuteAsync( + session, config, logger, ct); + + var total = result.DeletedByAge + result.DeletedByCount; + context.Result = total == 0 + ? "Nothing to delete" + : $"Deleted {total} system-job entries (age: {result.DeletedByAge}, count: {result.DeletedByCount})"; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs index 9bf8373d..72924459 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text.Json; using Modgud.Authentication.Applications; +using Modgud.Authentication.Sessions; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity; using Modgud.Authorization.Apps; @@ -275,6 +276,7 @@ private static async Task ExchangeAsync( SignInManager signInManager, UserManager userManager, IPermissionService permissionService, + IClientSessionService clientSessionService, CimdClientResolver cimdResolver, IEmailOtpService emailOtpService, RealmScopedFido2Factory fido2Factory, @@ -364,7 +366,68 @@ private static async Task ExchangeAsync( var principal = await CreateClaimsPrincipalAsync( user, request, scopeManager, originalScopes, userManager, cookiePrincipal: result.Principal); - principal.SetAuthorizationId(result.Principal?.GetAuthorizationId()); + + var authorizationId = result.Principal?.GetAuthorizationId(); + if (request.IsRefreshTokenGrantType()) + { + var rawClientSessionId = result.Principal? + .FindFirstValue(SessionClaimTypes.ClientSessionId); + if (!Guid.TryParse(rawClientSessionId, out var clientSessionId) || + string.IsNullOrEmpty(request.ClientId) || + await clientSessionService.ValidateAndTouchAsync( + user.Id, + clientSessionId, + request.ClientId, + authorizationId, + httpContext.RequestAborted) is null) + { + return ForbidInvalidGrant("The client session has expired or was revoked; please sign in again."); + } + + principal.SetAuthorizationId(authorizationId); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSessionId.ToString()); + } + else if (principal.HasScope(Scopes.OfflineAccess)) + { + var application = await applicationManager.FindByClientIdAsync(request.ClientId!) + ?? throw new InvalidOperationException("The application cannot be found."); + var clientPk = await applicationManager.GetIdAsync(application) ?? string.Empty; + var sessionAuthorization = await authorizationManager.CreateAsync( + principal: principal, + subject: user.Id.ToString(), + client: clientPk, + type: AuthorizationTypes.AdHoc, + scopes: principal.GetScopes()); + authorizationId = await authorizationManager.GetIdAsync(sessionAuthorization) + ?? throw new InvalidOperationException("The client-session authorization has no id."); + principal.SetAuthorizationId(authorizationId); + + var clientSession = await clientSessionService.CreateAsync( + new CreateClientSessionRequest( + user.Id, + request.ClientId!, + clientPk, + authorizationId, + await applicationManager.GetDisplayNameAsync(application), + httpContext.Connection.RemoteIpAddress?.ToString(), + httpContext.Request.Headers.UserAgent.ToString()), + httpContext.RequestAborted); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSession.Id.ToString()); + } + else + { + // No refresh token will be issued, so this is not a long-lived + // client/device session. Keep the authorization produced by the + // code/device flow and do not add an orphan ClientSession row. + principal.SetAuthorizationId(authorizationId); + } + + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSessionPolicy = await clientSessionService.ResolvePolicyAsync( + request.ClientId!, httpContext.RequestAborted); + principal.SetRefreshTokenLifetime(clientSessionPolicy.IdleLifetime); + } // Federation v1.1: bake the federated resource_access (durable ∪ // session-derived) into the access token HERE, while the carrier is @@ -473,7 +536,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativeOtpAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - emailOtpService, httpContext.RequestAborted); + clientSessionService, emailOtpService, httpContext.RequestAborted); } if (string.Equals(request.GrantType, CocoarGrantTypes.Magic, StringComparison.Ordinal)) @@ -481,7 +544,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativeMagicAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - httpContext.RequestAborted); + clientSessionService, httpContext.RequestAborted); } if (string.Equals(request.GrantType, CocoarGrantTypes.Passkey, StringComparison.Ordinal)) @@ -489,7 +552,7 @@ await BakeFederatedResourceAccessAsync( return await ExchangeNativePasskeyAsync( request, httpContext, applicationSettingsResolver, session, userManager, signInManager, scopeManager, applicationManager, authorizationManager, permissionService, - fido2Factory, rpIdResolver, httpContext.RequestAborted); + clientSessionService, fido2Factory, rpIdResolver, httpContext.RequestAborted); } throw new InvalidOperationException("The specified grant type is not supported."); @@ -564,6 +627,8 @@ private static async Task IssueNativeGrantAsync( IOpenIddictAuthorizationManager authorizationManager, IDocumentSession session, IPermissionService permissionService, + IClientSessionService clientSessionService, + HttpContext httpContext, NativeGrantSettings nativeSettings) { // userManager (NOT a plain session load) so the security stamp is @@ -574,26 +639,36 @@ private static async Task IssueNativeGrantAsync( await BakeFederatedResourceAccessAsync(principal, user.Id, request, session, permissionService); - // Find-or-create the permanent (subject, client) authorization so - // refresh, logout-all and revocation-by-authorization behave exactly like - // the authorization-code flow (mirrors AuthorizeAsync). Fail loud (like the - // code/refresh + client_credentials branches) rather than mint an - // authorization-less, non-revocable refresh chain — the client is - // guaranteed present here (OpenIddict's ValidateClientId + the per-client - // gt:urn:cocoar:* permission check both ran upstream). + // Each native device/login gets its own ad-hoc authorization. Consent is + // still represented by the permanent authorization created by the web + // flow; this authorization is solely the independently revocable token + // family root for one ClientSession. var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ?? throw new InvalidOperationException("The application cannot be found."); var subject = user.Id.ToString(); var clientPk = await applicationManager.GetIdAsync(application) ?? string.Empty; - var authorizations = await authorizationManager.FindAsync( - subject: subject, client: clientPk, status: Statuses.Valid, - type: AuthorizationTypes.Permanent, scopes: principal.GetScopes()).ToListAsync(); - var authorization = authorizations.LastOrDefault() - ?? await authorizationManager.CreateAsync( - principal: principal, subject: subject, client: clientPk, - type: AuthorizationTypes.Permanent, scopes: principal.GetScopes()); - principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + var authorization = await authorizationManager.CreateAsync( + principal: principal, subject: subject, client: clientPk, + type: AuthorizationTypes.AdHoc, scopes: principal.GetScopes()); + var authorizationId = await authorizationManager.GetIdAsync(authorization) + ?? throw new InvalidOperationException("The client-session authorization has no id."); + principal.SetAuthorizationId(authorizationId); + + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSession = await clientSessionService.CreateAsync( + new CreateClientSessionRequest( + user.Id, + request.ClientId!, + clientPk, + authorizationId, + await applicationManager.GetDisplayNameAsync(application), + httpContext.Connection.RemoteIpAddress?.ToString(), + httpContext.Request.Headers.UserAgent.ToString()), + httpContext.RequestAborted); + principal.SetClaim(SessionClaimTypes.ClientSessionId, clientSession.Id.ToString()); + } // ADR-0010 — short JWT access TTL for native clients (per-realm tunable, // validated at write time). Clamp defensively so even a settings doc @@ -602,8 +677,12 @@ private static async Task IssueNativeGrantAsync( // JWT access token. The refresh token stays a revocable reference token. principal.SetAccessTokenLifetime( ClampLifetime(nativeSettings.AccessTokenLifetime, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(60))); - principal.SetRefreshTokenLifetime( - ClampLifetime(nativeSettings.RefreshTokenLifetime, TimeSpan.FromDays(1), TimeSpan.FromDays(30))); + if (principal.HasScope(Scopes.OfflineAccess)) + { + var clientSessionPolicy = await clientSessionService.ResolvePolicyAsync( + request.ClientId!, httpContext.RequestAborted); + principal.SetRefreshTokenLifetime(clientSessionPolicy.IdleLifetime); + } return Results.SignIn(principal, properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -642,6 +721,7 @@ private static async Task ExchangeNativeOtpAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, IEmailOtpService emailOtpService, CancellationToken ct) { @@ -696,7 +776,8 @@ private static async Task ExchangeNativeOtpAsync( return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } /// urn:cocoar:magic — verify a magic-link (user_id + token) @@ -715,6 +796,7 @@ private static async Task ExchangeNativeMagicAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, CancellationToken ct) { var nativeSettings = await LoadNativeGrantSettingsAsync(settingsResolver, httpContext, request.ClientId, ct); @@ -776,7 +858,8 @@ private static async Task ExchangeNativeMagicAsync( return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } /// urn:cocoar:passkey — verify a WebAuthn assertion against a @@ -797,6 +880,7 @@ private static async Task ExchangeNativePasskeyAsync( IOpenIddictApplicationManager applicationManager, IOpenIddictAuthorizationManager authorizationManager, IPermissionService permissionService, + IClientSessionService clientSessionService, RealmScopedFido2Factory fido2Factory, RpIdResolver rpIdResolver, CancellationToken ct) @@ -912,7 +996,8 @@ private static async Task ExchangeNativePasskeyAsync( // (the begin endpoint requires UV), so we do not additionally demand totp_code. return await IssueNativeGrantAsync( user, request, scopeManager, userManager, applicationManager, - authorizationManager, session, permissionService, nativeSettings); + authorizationManager, session, permissionService, clientSessionService, + httpContext, nativeSettings); } private static async Task UserinfoAsync( @@ -1873,6 +1958,7 @@ public static IEnumerable GetDestinations(Claim claim) yield break; case "AspNet.Identity.SecurityStamp": + case SessionClaimTypes.ClientSessionId: yield break; // Federation v1 (hub boundary, decision D): the session-group carrier diff --git a/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs b/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs index b74f67ef..895f4354 100644 --- a/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs +++ b/src/dotnet/Modgud.Api/Features/Inbox/InboxRetentionJob.cs @@ -1,16 +1,12 @@ -using Microsoft.Extensions.DependencyInjection; using Quartz; using Modgud.Application.Inbox; -using Modgud.Infrastructure.Persistence.Tenancy; -using Modgud.Infrastructure.Realms; namespace Modgud.Api.Features.Inbox; /// /// Quartz wrapper around . Runs daily -/// (default 03:00 UTC) and applies the per-kind retention policy stored in -/// for every active realm — each tenant -/// has its own retention settings doc in its own DB. +/// (default 03:00 UTC) and applies the owning realm's per-kind retention policy +/// stored in . /// /// The job itself is intentionally dumb — no parameter schema, no per-run /// config. Admins configure retention under /admin/inbox-settings, @@ -18,54 +14,23 @@ namespace Modgud.Api.Features.Inbox; /// [DisallowConcurrentExecution] public class InboxRetentionJob( - IServiceScopeFactory scopeFactory, - IRealmCache realmCache) : IJob + IInboxRetentionService retention) : IJob { public const string Key = "inbox-retention"; public const string Name = "Inbox Retention"; public const string Description = - "Applies the inbox retention policy (configured under /admin/inbox-settings) " + - "across every active realm."; + "Applies this realm's inbox retention policy (configured under /admin/inbox-settings)."; /// 03:00 UTC every day — before the other two retention jobs. public const string DefaultCron = "0 0 3 * * ?"; public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - var realms = await realmCache.GetAllActiveAsync(); + var result = await retention.ExecuteAsync(ct); - int totalAffected = 0; - var breakdown = new Dictionary(); - int tenantsProcessed = 0; - - foreach (var realm in realms) - { - try - { - using var scope = scopeFactory.CreateScope(); - using var _ = TenantContext.Enter(realm.Slug); - - var retention = scope.ServiceProvider.GetRequiredService(); - var result = await retention.ExecuteAsync(ct); - - totalAffected += result.TotalAffected; - foreach (var (reason, count) in result.AffectedByReason) - { - breakdown[reason] = breakdown.GetValueOrDefault(reason) + count; - } - tenantsProcessed++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Serilog.Log.Error(ex, - "inbox-retention failed for realm {Slug}", - realm.Slug); - } - } - - context.Result = totalAffected == 0 - ? $"Nothing to do ({tenantsProcessed} tenant(s) checked)" - : $"Touched {totalAffected} item(s) across {tenantsProcessed} tenant(s) — " + - string.Join(", ", breakdown.Select(kv => $"{kv.Key}={kv.Value}")); + context.Result = result.TotalAffected == 0 + ? "Nothing to do" + : $"Touched {result.TotalAffected} item(s) — " + + string.Join(", ", result.AffectedByReason.Select(kv => $"{kv.Key}={kv.Value}")); } } diff --git a/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs b/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs index e15737f2..8b3cfa72 100644 --- a/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs +++ b/src/dotnet/Modgud.Api/HealthChecks/MartenSchemaHealthCheck.cs @@ -6,18 +6,16 @@ namespace Modgud.Api.HealthChecks; /// -/// End-to-end Marten readiness probe: opens a session against the master -/// tenant () and runs a no-op -/// query against the Realm document. If the Marten schema isn't applied, -/// the master DB connection string is wrong, or the multi-tenant master -/// table isn't readable, this fails — which is exactly what readiness -/// should refuse traffic for. +/// End-to-end Marten readiness probe: opens the global store and runs a no-op +/// query against the Realm directory. If the Marten schema isn't applied or +/// the master DB connection string is wrong, this fails — which is exactly +/// what readiness should refuse traffic for. /// /// Per-tenant DBs are deliberately NOT probed here — they're /// initialised on-demand and the count grows over time. Probing each /// would make readiness latency O(realms). /// -public sealed class MartenSchemaHealthCheck(IDocumentStore store) : IHealthCheck +public sealed class MartenSchemaHealthCheck(IGlobalStore globalStore) : IHealthCheck { public async Task CheckHealthAsync( HealthCheckContext context, @@ -25,7 +23,7 @@ public async Task CheckHealthAsync( { try { - await using var session = store.QuerySession(TenantConstants.SystemTenantId); + await using var session = globalStore.QuerySession(); var _ = await session.Query().AnyAsync(cancellationToken); return HealthCheckResult.Healthy("Marten master schema reachable."); } diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 739473a8..929ad7c8 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.AspNetCore.SignalR; using Serilog; using Serilog.Sinks.OpenTelemetry; using Serilog.Sinks.SystemConsole.Themes; @@ -245,7 +246,13 @@ }); - builder.Services.AddSignalR() + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSignalR(options => + { + options.AddFilter(); + }) .AddJsonProtocol(options => { options.PayloadSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; @@ -341,6 +348,11 @@ if (!newIdentity.HasClaim(claim.Type, claim.Value)) newIdentity.AddClaim(new Claim(claim.Type, claim.Value)); + foreach (var claim in current.FindAll( + Modgud.Authentication.Sessions.SessionClaimTypes.BrowserSessionId)) + if (!newIdentity.HasClaim(claim.Type, claim.Value)) + newIdentity.AddClaim(new Claim(claim.Type, claim.Value)); + return Task.CompletedTask; }; }); @@ -348,6 +360,7 @@ builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme) .AddCookie(IdentityConstants.ApplicationScheme, options => { + options.EventsType = typeof(Modgud.Authentication.Sessions.BrowserSessionCookieEvents); options.Cookie.HttpOnly = true; // COOKIE-01: Lax (was Strict). Strict prevents the browser from sending // the cookie on top-level navigations from third-party origins — which @@ -370,37 +383,9 @@ options.CookieManager = new Modgud.Api.Cookies.TenantApexCookieManager(); options.ExpireTimeSpan = TimeSpan.FromDays(30); // Max lifetime for persistent (RememberMe) cookies options.SlidingExpiration = true; - // SESSION-01 — re-validate the user's security stamp on every - // request (with a small per-request cache configured via - // SecurityStampValidatorOptions.ValidationInterval). When the - // stamp on disk no longer matches the cookie's stamp, the - // cookie is rejected and the user must re-authenticate. - options.Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync; - options.Events.OnRedirectToLogin = ctx => - { - // /api/* is the SPA's data plane — surface 401 so the - // SPA can decide where to navigate. Everything else - // (including /connect/authorize for inbound OAuth flows - // from third-party clients) needs a real 302 redirect - // so the browser actually lands on the login page. - if (ctx.Request.Path.StartsWithSegments("/api")) - { - ctx.Response.StatusCode = 401; - return Task.CompletedTask; - } - ctx.Response.Redirect(ctx.RedirectUri); - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = ctx => - { - if (ctx.Request.Path.StartsWithSegments("/api")) - { - ctx.Response.StatusCode = 403; - return Task.CompletedTask; - } - ctx.Response.Redirect(ctx.RedirectUri); - return Task.CompletedTask; - }; + // BrowserSessionCookieEvents performs authoritative session + // validation, delegates security-stamp validation and preserves + // the API-specific 401/403 redirect behavior. // Login/access-denied paths — the SPA handles these client-side // (Vue Router routes for /login + /access-denied), but they need // to be valid URLs so the redirect emitted above resolves to the @@ -738,6 +723,12 @@ Modgud.Authentication.Sessions.DeviceInfoService>(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); + builder.Services.AddScoped(sp => + sp.GetRequiredService()); builder.Services.AddScoped(); @@ -955,37 +946,50 @@ sp => sp.GetRequiredService()); builder.Services.AddHostedService(); - // Quartz-based scheduling framework + the system jobs we host. The DCR - // garbage collector was a hand-rolled BackgroundService before Phase 1A; - // now it runs as a Quartz job so admins can see runs, override the cron, - // and trigger manually from /admin/jobs. + // Quartz-based scheduling framework. Realm jobs get one independent + // Quartz job + trigger per realm; deployment-wide jobs are registered once + // and are visible only from the current Control-Plane realm. builder.Services.AddScheduling(); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Key, name: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.Description, getParameterSchema: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.GetParameterSchema); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Key, name: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.DcrGcJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.DcrGcJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Inbox.InboxRetentionJob.Key, name: Modgud.Api.Features.Inbox.InboxRetentionJob.Name, defaultCron: Modgud.Api.Features.Inbox.InboxRetentionJob.DefaultCron, description: Modgud.Api.Features.Inbox.InboxRetentionJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Key, name: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.AccountLifecycleSweepJob.Description); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( + key: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Key, + name: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.SessionPruneJob.Description); + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Key, name: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.DefaultCron, - description: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Description); + description: Modgud.Api.Features.Admin.Jobs.SigningKeyJanitorJob.Description, + // Soft-delete keeps the tenant DB and its private key material. This + // realm-owned hygiene therefore continues while the realm is inactive. + runWhenRealmInactive: true); + builder.Services.AddSystemJob( + key: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Key, + name: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Description, + getParameterSchema: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.GetParameterSchema); builder.Services.AddSystemJob( key: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Key, name: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Name, diff --git a/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs b/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs new file mode 100644 index 00000000..ec235aaa --- /dev/null +++ b/src/dotnet/Modgud.Api/Realtime/BrowserSessionHubFilter.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.SignalR; +using Modgud.Authentication.Sessions; + +namespace Modgud.Api.Realtime; + +/// +/// Binds every authenticated SignalR connection to the browser-session claim. +/// Targeted revocation aborts the upgraded connection immediately; each hub +/// invocation also re-checks the authoritative row. +/// +public sealed class BrowserSessionHubFilter( + IBrowserSessionConnectionRegistry connections) : IHubFilter +{ + private readonly ConcurrentDictionary _registrations = new(); + + public async Task OnConnectedAsync( + HubLifetimeContext context, + Func next) + { + var http = context.Context.GetHttpContext(); + var raw = context.Context.User?.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (http is null || !Guid.TryParse(raw, out var sessionId)) + { + context.Context.Abort(); + return; + } + + var registration = connections.Register( + sessionId, context.Context.ConnectionId, http); + if (_registrations.TryGetValue(context.Context.ConnectionId, out var previous)) + previous.Dispose(); + _registrations[context.Context.ConnectionId] = registration; + + try + { + await next(context); + } + catch + { + RemoveRegistration(context.Context.ConnectionId); + throw; + } + } + + public async Task OnDisconnectedAsync( + HubLifetimeContext context, + Exception? exception, + Func next) + { + RemoveRegistration(context.Context.ConnectionId); + await next(context, exception); + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + var http = invocationContext.Context.GetHttpContext(); + var userId = http?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var rawSessionId = http?.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (http is null || + !Guid.TryParse(userId, out var parsedUserId) || + !Guid.TryParse(rawSessionId, out var sessionId)) + { + invocationContext.Context.Abort(); + throw new HubException("The browser session is no longer valid."); + } + + await using var scope = http.RequestServices.CreateAsyncScope(); + var sessions = scope.ServiceProvider.GetRequiredService(); + if (await sessions.ValidateSessionAsync( + parsedUserId, sessionId, touch: true, http.RequestAborted) is null) + { + invocationContext.Context.Abort(); + throw new HubException("The browser session is no longer valid."); + } + + return await next(invocationContext); + } + + private void RemoveRegistration(string connectionId) + { + if (_registrations.TryRemove(connectionId, out var registration)) + registration.Dispose(); + } +} diff --git a/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs index ab15f027..9a414d7f 100644 --- a/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/Applications/ApplicationSettingsDtos.cs @@ -15,6 +15,7 @@ public record ApplicationSettingsDto public ApplicationEmailBrandingDto? EmailBranding { get; init; } public ApplicationSelfRegistrationDto? SelfRegistration { get; init; } public ApplicationNativeGrantsDto? NativeGrants { get; init; } + public ApplicationClientSessionsDto? ClientSessions { get; init; } public ApplicationDcrDto? Dcr { get; init; } public ApplicationCimdDto? Cimd { get; init; } public ApplicationRegistrationFieldsDto? RegistrationFields { get; init; } @@ -64,6 +65,12 @@ public record ApplicationNativeGrantsDto public int? RefreshTokenLifetimeDays { get; init; } } +public record ApplicationClientSessionsDto +{ + public int? IdleLifetimeDays { get; init; } + public int? AbsoluteLifetimeDays { get; init; } +} + public record ApplicationDcrDto { public bool? Enabled { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs index 055d87bb..ba73672c 100644 --- a/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/OAuth/OAuthClientDtos.cs @@ -28,7 +28,8 @@ public record OAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } - + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } public bool AlwaysSendClientClaims { get; init; } public bool UpdateAccessTokenClaimsOnRefresh { get; init; } public string? ClientClaimsPrefix { get; init; } @@ -139,6 +140,8 @@ public record CreateOAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } public bool AlwaysSendClientClaims { get; init; } public bool UpdateAccessTokenClaimsOnRefresh { get; init; } @@ -201,6 +204,10 @@ public record UpdateOAuthClientDto public int? AccessTokenLifetime { get; init; } public int? AuthorizationCodeLifetime { get; init; } public int? SlidingRefreshTokenLifetime { get; init; } + public int? ClientSessionIdleLifetime { get; init; } + public int? ClientSessionAbsoluteLifetime { get; init; } + public bool ClearClientSessionIdleLifetime { get; init; } + public bool ClearClientSessionAbsoluteLifetime { get; init; } public bool? AlwaysSendClientClaims { get; init; } public bool? UpdateAccessTokenClaimsOnRefresh { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs index eb8546b1..cf458ec4 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs @@ -16,6 +16,8 @@ public record RealmSettingsDto public DcrSettingsDto Dcr { get; init; } = new(); public CimdSettingsDto Cimd { get; init; } = new(); public NativeGrantSettingsDto NativeGrants { get; init; } = new(); + public BrowserSessionPolicyDto BrowserSessions { get; init; } = new(); + public ClientSessionPolicyDto ClientSessions { get; init; } = new(); public AuthRateLimitsDto AuthRateLimits { get; init; } = new(); public BrandingSettingsDto Branding { get; init; } = new(); public RegistrationFieldsSettingsDto RegistrationFields { get; init; } = new(); @@ -37,6 +39,8 @@ public record UpdateRealmSettingsDto public UpdateDcrSettingsDto? Dcr { get; init; } public UpdateCimdSettingsDto? Cimd { get; init; } public UpdateNativeGrantSettingsDto? NativeGrants { get; init; } + public UpdateBrowserSessionPolicyDto? BrowserSessions { get; init; } + public UpdateClientSessionPolicyDto? ClientSessions { get; init; } public UpdateAuthRateLimitsDto? AuthRateLimits { get; init; } public UpdateBrandingSettingsDto? Branding { get; init; } public UpdateRegistrationFieldsSettingsDto? RegistrationFields { get; init; } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs new file mode 100644 index 00000000..a3c009c3 --- /dev/null +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/SessionPolicyDtos.cs @@ -0,0 +1,27 @@ +namespace Modgud.Application.DTOs.RealmSettings; + +public record BrowserSessionPolicyDto +{ + public int IdleLifetimeMinutes { get; init; } + public int AbsoluteLifetimeMinutes { get; init; } + public bool AllowRememberMe { get; init; } +} + +public record UpdateBrowserSessionPolicyDto +{ + public int? IdleLifetimeMinutes { get; init; } + public int? AbsoluteLifetimeMinutes { get; init; } + public bool? AllowRememberMe { get; init; } +} + +public record ClientSessionPolicyDto +{ + public int IdleLifetimeDays { get; init; } + public int AbsoluteLifetimeDays { get; init; } +} + +public record UpdateClientSessionPolicyDto +{ + public int? IdleLifetimeDays { get; init; } + public int? AbsoluteLifetimeDays { get; init; } +} diff --git a/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs b/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs index 5d1c35a9..42a97b87 100644 --- a/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs +++ b/src/dotnet/Modgud.Application/Scheduling/IJobsService.cs @@ -2,9 +2,10 @@ namespace Modgud.Application.Scheduling; /// /// Admin-facing facade combining the static job registry (compiled jobs), -/// the persisted JobConfig overrides, the running Quartz scheduler, -/// and the JobRunHistoryEntry ledger. Used by the admin endpoints -/// at /api/admin/jobs; not consumed from the request path elsewhere. +/// persisted JobConfig overrides, Quartz identities, and the +/// JobRunHistoryEntry ledger. Realm state is tenant-owned. System state +/// lives in the non-tenanted global store and is exposed only when the current +/// realm is the Control Plane. /// public interface IJobsService { @@ -34,6 +35,7 @@ public sealed record JobOverviewDto public required string Name { get; init; } public string? Description { get; init; } public required string Kind { get; init; } // "System" | "Script" + public required string Scope { get; init; } // "Realm" | "System" public required string EffectiveCron { get; init; } // override if present, else default public required string DefaultCron { get; init; } public bool HasOverride { get; init; } diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs index 0af0ae9f..1c5431fa 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminMapping.cs @@ -168,6 +168,8 @@ internal static Dictionary BuildClientSettings(CreateOAuthClient if (dto.AccessTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.AccessTokenLifetime] = dto.AccessTokenLifetime.Value.ToString(); if (dto.AuthorizationCodeLifetime.HasValue) settings[OAuthApplicationSettingKeys.AuthorizationCodeLifetime] = dto.AuthorizationCodeLifetime.Value.ToString(); if (dto.SlidingRefreshTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime] = dto.SlidingRefreshTokenLifetime.Value.ToString(); + if (dto.ClientSessionIdleLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = dto.ClientSessionIdleLifetime.Value.ToString(); + if (dto.ClientSessionAbsoluteLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = dto.ClientSessionAbsoluteLifetime.Value.ToString(); if (dto.ClientClaimsPrefix is not null) settings[OAuthApplicationSettingKeys.ClientClaimsPrefix] = dto.ClientClaimsPrefix; // ADR-0009 — store the normalized (trimmed, lowercased) per-client RP ID; a // blank value leaves it realm-scoped (no key). Format is validated upstream. @@ -304,6 +306,10 @@ internal static Dictionary MergeClientSettings( if (dto.AccessTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.AccessTokenLifetime] = dto.AccessTokenLifetime.Value.ToString(); if (dto.AuthorizationCodeLifetime.HasValue) settings[OAuthApplicationSettingKeys.AuthorizationCodeLifetime] = dto.AuthorizationCodeLifetime.Value.ToString(); if (dto.SlidingRefreshTokenLifetime.HasValue) settings[OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime] = dto.SlidingRefreshTokenLifetime.Value.ToString(); + if (dto.ClearClientSessionIdleLifetime) settings.Remove(OAuthApplicationSettingKeys.ClientSessionIdleLifetime); + if (dto.ClearClientSessionAbsoluteLifetime) settings.Remove(OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime); + if (dto.ClientSessionIdleLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = dto.ClientSessionIdleLifetime.Value.ToString(); + if (dto.ClientSessionAbsoluteLifetime.HasValue) settings[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = dto.ClientSessionAbsoluteLifetime.Value.ToString(); if (dto.ClientClaimsPrefix is not null) settings[OAuthApplicationSettingKeys.ClientClaimsPrefix] = dto.ClientClaimsPrefix; // ADR-0009 PATCH: null = omit; empty/blank = clear back to realm-scoped; // non-blank = set (normalized). Format is validated upstream. @@ -462,6 +468,26 @@ internal static Dictionary MergeClientSettings( private static string ToLifetimeString(int seconds) => TimeSpan.FromSeconds(seconds).ToString("c", CultureInfo.InvariantCulture); + internal static Error? ValidateClientSessionLifetimes( + int? idleLifetimeSeconds, + int? absoluteLifetimeSeconds) + { + const int min = 24 * 60 * 60; + const int max = 3650 * 24 * 60 * 60; + + if (idleLifetimeSeconds is { } idle && (idle < min || idle > max)) + return Error.Validation("OAuthClient.InvalidClientSessionIdleLifetime", + $"ClientSessionIdleLifetime must be between {min} and {max} seconds."); + if (absoluteLifetimeSeconds is { } absolute && (absolute < min || absolute > max)) + return Error.Validation("OAuthClient.InvalidClientSessionAbsoluteLifetime", + $"ClientSessionAbsoluteLifetime must be between {min} and {max} seconds."); + if (idleLifetimeSeconds is { } i && absoluteLifetimeSeconds is { } a && a < i) + return Error.Validation("OAuthClient.InvalidClientSessionAbsoluteLifetime", + "ClientSessionAbsoluteLifetime must be at least ClientSessionIdleLifetime."); + + return null; + } + /// /// Merges an over the client's /// Properties dictionary. Each property field on the DTO is @@ -538,6 +564,8 @@ internal static OAuthClientDto MapClient(OAuthApplicationState s) AccessTokenLifetime = GetIntSetting(OAuthApplicationSettingKeys.AccessTokenLifetime), AuthorizationCodeLifetime = GetIntSetting(OAuthApplicationSettingKeys.AuthorizationCodeLifetime), SlidingRefreshTokenLifetime = GetIntSetting(OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime), + ClientSessionIdleLifetime = GetIntSetting(OAuthApplicationSettingKeys.ClientSessionIdleLifetime), + ClientSessionAbsoluteLifetime = GetIntSetting(OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime), AlwaysSendClientClaims = GetBoolProp(props, OAuthApplicationPropertyKeys.AlwaysSendClientClaims, false), UpdateAccessTokenClaimsOnRefresh = GetBoolProp(props, OAuthApplicationPropertyKeys.UpdateAccessTokenClaimsOnRefresh, false), ClientClaimsPrefix = prefix, diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs index 7da404ee..5cbb6b0a 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs @@ -165,6 +165,10 @@ public async Task> CreateClientAsync( // Settings (primitive lifetime + token-type values). var settings = BuildClientSettings(dto); + if (ValidateClientSessionLifetimes( + dto.ClientSessionIdleLifetime, + dto.ClientSessionAbsoluteLifetime) is { } clientSessionError) + return clientSessionError; if (dcrMetadata is null) { // Issue #115 — standard (non-DCR) clients: wire the admin's @@ -341,6 +345,15 @@ public async Task> UpdateClientAsync( // Settings — partial-PATCH merge; only emit the event when the merge // actually produced a different dictionary. var newSettings = MergeClientSettings(aggregate.Settings, dto); + if ((dto.ClearClientSessionIdleLifetime && dto.ClientSessionIdleLifetime.HasValue) || + (dto.ClearClientSessionAbsoluteLifetime && dto.ClientSessionAbsoluteLifetime.HasValue)) + return Error.Validation( + "OAuthClient.ConflictingClientSessionLifetimeUpdate", + "A client-session lifetime cannot be set and cleared in the same update."); + if (ValidateClientSessionLifetimes( + dto.ClientSessionIdleLifetime, + dto.ClientSessionAbsoluteLifetime) is { } clientSessionError) + return clientSessionError; // Issue #115 — same native tkn_lft:* wiring as CreateClientAsync, PATCH // semantics: a field omitted from the DTO leaves any existing diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs index 92af869a..30b4107c 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs @@ -75,7 +75,6 @@ public static WebApplication MapAccountEndpoints(this WebApplication application IAuthSettings appSettings, IDocumentSession docSession, IQuerySession session, - ISessionService sessionService, ISecurityAuditLog securityAudit, HttpContext context) => { @@ -161,15 +160,12 @@ public static WebApplication MapAccountEndpoints(this WebApplication application Log.Information("Login successful. UserId={UserId} IP={IP}", user.Id, ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Success); - // Track per-user device session (best-effort). - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); - // Audit marker on the user's stream (Phase 1): the "when + by what - // method" of a successful login. No IP on the event — IP/device live - // in the Sessions feature (RecordLoginAsync above). Erasable with the - // user. Best-effort: PasswordSignInAsync has already issued the auth - // cookie, so a failed marker write must NOT turn a successful login - // into a 500 — log and continue (mirrors SessionTracker's contract). + // method" of a successful login. No IP on the event — the authoritative + // browser session created by the cookie event owns IP/device metadata. + // Erasable with the user. Best-effort: PasswordSignInAsync has already + // issued the auth cookie, so a failed marker write must NOT turn a + // successful login into a 500 — log and continue. try { docSession.Events.Append(user.Id, new Modgud.Authentication.Events.UserLoggedInEvent( @@ -350,7 +346,6 @@ public static WebApplication MapAccountEndpoints(this WebApplication application UserManager userManager, SignInManager signInManager, IUserAccessRevoker accessRevoker, - ISessionService sessionService, IAuthSettings appSettings) => { if (appSettings.AuthenticationMinimumLevel >= 2) @@ -374,15 +369,14 @@ public static WebApplication MapAccountEndpoints(this WebApplication application // session — not merely rely on the <=5-min security-stamp window. Kill // everything, then refresh the CURRENT session (reload the user first so // it carries the freshly-rotated stamp) so the password-changer stays - // signed in here, and re-record its device row so the session list stays - // accurate. + // signed in here. BrowserSessionCookieEvents creates the replacement + // authoritative row as part of the refreshed cookie. await accessRevoker.RevokeAllAccessAsync( user.Id, AccessRevocationReason.ForceSignOut, context.RequestAborted); var refreshed = await userManager.FindByIdAsync(user.Id.ToString()); if (refreshed is not null) { await signInManager.RefreshSignInAsync(refreshed); - await SessionTracker.RecordLoginAsync(sessionService, context, refreshed.Id); } Log.Information("Password changed; other sessions revoked. UserId={UserId} IP={IP}", user.Id, ip); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs index 8f8f1edd..cbb2a8a1 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs @@ -43,7 +43,6 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri IPendingAdminInviteService inviteService, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, ISecurityAuditLog securityAudit) => { var ip = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; @@ -74,7 +73,6 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri if (user is not null) { await signInManager.SignInAsync(user, isPersistent: false); - await SessionTracker.RecordLoginAsync(sessionService, http, user.Id); } Serilog.Log.Warning( diff --git a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs index 98889f76..7862e662 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/EmailOtpEndpoints.cs @@ -159,7 +159,6 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio HttpContext context, SignInManager signInManager, IEmailOtpService emailOtpService, - ISessionService sessionService, CancellationToken ct) => { // Empty body / missing field — reject at the boundary instead of letting the service NRE. @@ -201,7 +200,6 @@ public static WebApplication MapEmailOtpEndpoints(this WebApplication applicatio await context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); await signInManager.SignInAsync(user, isPersistent: request.RememberMe); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.EmailOtp, ModgudMeters.LoginOutcome.Success); return Results.Ok(new { Message = "Login successful" }); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs index ef5eb3d1..97e76148 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs @@ -172,7 +172,6 @@ await emailService.SendTemplatedEmailAsync( IDocumentSession session, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, ISecurityAuditLog securityAudit, HttpContext context) => { @@ -314,7 +313,6 @@ await context.SignInAsync( // Sign in — Magic Link is always persistent; user can request a new link anytime. await signInManager.SignInAsync(user, isPersistent: true); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); Serilog.Log.Information("Magic link login successful. UserId={UserId} IP={IP}", user.Id, ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Success); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs index 2c13841b..841be9cc 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MfaEndpoints.cs @@ -167,7 +167,6 @@ public static WebApplication MapMfaEndpoints(this WebApplication application, st group.MapPost("login", async ( SignInManager signInManager, MfaLoginRequest request, - ISessionService sessionService, HttpContext context) => { var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; @@ -202,7 +201,6 @@ public static WebApplication MapMfaEndpoints(this WebApplication application, st if (result.Succeeded) { if (twoFactorUser is not null) - await SessionTracker.RecordLoginAsync(sessionService, context, twoFactorUser.Id); Serilog.Log.Information("MFA login successful. IP={IP}", ip); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Mfa, ModgudMeters.LoginOutcome.Success); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs index a542f9e8..9696dedb 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/PasskeyEndpoints.cs @@ -364,7 +364,6 @@ public static WebApplication MapPasskeyEndpoints(this WebApplication application IDocumentSession session, UserManager userManager, SignInManager signInManager, - ISessionService sessionService, JsonElement body, CancellationToken ct) => { @@ -444,7 +443,6 @@ public static WebApplication MapPasskeyEndpoints(this WebApplication application // Passkey login is always persistent — user can re-authenticate anytime via biometrics await signInManager.SignInAsync(user, isPersistent: true); - await SessionTracker.RecordLoginAsync(sessionService, context, user.Id); var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; Serilog.Log.Information("Passkey login successful. UserId={UserId} IP={IP}", user.Id, ip); diff --git a/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs index 954b298b..6f7c7286 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/SessionEndpoints.cs @@ -4,6 +4,7 @@ using Modgud.Authentication.Sessions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Authentication; namespace Modgud.Authentication.Api.Account; @@ -24,12 +25,23 @@ public static WebApplication MapSessionEndpoints(this WebApplication application group.MapGet("", [Authorize] async ( HttpContext context, ISessionService svc, + IClientSessionService clientSessions, CancellationToken ct) => { var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); - var result = await svc.GetSessionsAsync(userId.Value, currentSessionId: null, ct); + var currentSessionId = Guid.TryParse( + context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value, + out var parsedSessionId) + ? parsedSessionId + : (Guid?)null; + var result = await svc.GetSessionsAsync(userId.Value, currentSessionId, ct); + if (!result.IsError) + { + var clients = await clientSessions.GetSessionsAsync(userId.Value, ct); + result = result.Value with { ClientSessions = clients.ToList() }; + } return result.ToResult(); }) .WithName("Auth_Sessions_List"); @@ -50,11 +62,42 @@ public static WebApplication MapSessionEndpoints(this WebApplication application var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); + var current = context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (Guid.TryParse(current, out var currentSessionId) && currentSessionId == id) + return Results.Conflict(new { Error = "Use normal logout to end the current browser session." }); + var result = await svc.RevokeSessionAsync(userId.Value, id, ct); return result.IsError ? result.ToResult() : Results.NoContent(); }) .WithName("Auth_Sessions_Revoke"); + group.MapDelete("client/{id:guid}", [Authorize] async ( + Guid id, + HttpContext context, + IClientSessionService clientSessions, + CancellationToken ct) => + { + var userId = context.GetUserId(); + if (userId is null) return Results.Unauthorized(); + var result = await clientSessions.RevokeAsync(userId.Value, id, ct); + return result.IsError ? result.ToResult() : Results.NoContent(); + }) + .WithName("Auth_ClientSessions_Revoke"); + + group.MapDelete("others", [Authorize] async ( + HttpContext context, + ISessionService svc, + CancellationToken ct) => + { + var userId = context.GetUserId(); + var raw = context.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is null || !Guid.TryParse(raw, out var currentSessionId)) + return Results.Unauthorized(); + var result = await svc.RevokeAllSessionsAsync(userId.Value, currentSessionId, ct); + return result.IsError ? result.ToResult() : Results.NoContent(); + }) + .WithName("Auth_Sessions_RevokeOthers"); + // DELETE /api/auth/sessions — revoke all my sessions (logout everywhere). // Audit remediation #1: RevokeAllSessionsAsync alone only deleted tracking // rows — invisible to the cookie middleware, so other devices stayed signed @@ -64,27 +107,14 @@ public static WebApplication MapSessionEndpoints(this WebApplication application // THIS request so the acting device stays signed in; all others die. group.MapDelete("", [Authorize] async ( HttpContext context, - UserManager userManager, - SignInManager signInManager, IUserAccessRevoker accessRevoker, - ISessionService sessionService, CancellationToken ct) => { var userId = context.GetUserId(); if (userId is null) return Results.Unauthorized(); await accessRevoker.RevokeAllAccessAsync(userId.Value, AccessRevocationReason.ForceSignOut, ct); - var user = await userManager.FindByIdAsync(userId.Value.ToString()); - if (user is not null) - { - await signInManager.RefreshSignInAsync(user); - // RevokeAllAccessAsync deleted EVERY session row, including the acting - // device's. RefreshSignInAsync keeps this device signed in but doesn't - // re-track it — so without this the user's own "active sessions" list - // would read empty until their next fresh login. Re-record the acting - // session so the live device reappears. - await SessionTracker.RecordLoginAsync(sessionService, context, userId.Value, ct); - } + await context.SignOutAsync(IdentityConstants.ApplicationScheme); return Results.NoContent(); }) .WithName("Auth_Sessions_RevokeAll"); diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs index 6cc9cc3d..cd011fcc 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AdminSessionEndpoints.cs @@ -23,10 +23,16 @@ public static WebApplication MapAdminSessionEndpoints(this WebApplication applic group.MapGet("{id}/sessions", async ( string id, ISessionService svc, + IClientSessionService clientSessions, CancellationToken ct) => { var userId = ShortGuid.Decode(id); var result = await svc.GetSessionsAsync(userId, currentSessionId: null, ct); + if (!result.IsError) + { + var clients = await clientSessions.GetSessionsAsync(userId, ct); + result = result.Value with { ClientSessions = clients.ToList() }; + } return result.ToResult(); }) .WithName("Admin_Sessions_List") diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs index 6162bf3f..ec347f57 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs @@ -115,7 +115,6 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints async (HttpContext http, [FromServices] ExternalLoginProcessor processor, [FromServices] Microsoft.AspNetCore.Identity.SignInManager signInManager, - [FromServices] Modgud.Authentication.Sessions.ISessionService sessionService, CancellationToken ct) => { var auth = await http.AuthenticateAsync(Microsoft.AspNetCore.Identity.IdentityConstants.ExternalScheme); @@ -163,11 +162,6 @@ await http.SignInAsync( // the application cookie — defense against stale claim-replay. await http.SignOutAsync(Microsoft.AspNetCore.Identity.IdentityConstants.ExternalScheme); - // Track per-user device session (best-effort). - var signedInIdClaim = result.Principal!.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - if (Guid.TryParse(signedInIdClaim, out var signedInUserId)) - await Modgud.Authentication.Sessions.SessionTracker.RecordLoginAsync(sessionService, http, signedInUserId, ct); - var returnUrl = auth.Properties.Items.TryGetValue("returnUrl", out var ru) && !string.IsNullOrWhiteSpace(ru) ? ru : "/"; diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs index 0c029e35..708d20ed 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Identity; using Modgud.Authentication.Domain; using Modgud.Authentication.Identity.LoginProviders.Saml; -using Modgud.Authentication.Sessions; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Observability; @@ -33,9 +32,7 @@ public class SamlLoginFlow( SamlContextBuilder contextBuilder, SamlSpCertificateService spCertService, ExternalLoginProcessor processor, - SignInManager signInManager, ISamlAuthnRequestStore authnRequestStore, - ISessionService sessionService, ISecurityAuditLog securityAudit, ILogger logger) { @@ -310,10 +307,6 @@ await http.SignInAsync( ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Success); - var signedInIdClaim = result.Principal!.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (Guid.TryParse(signedInIdClaim, out var signedInUserId)) - await SessionTracker.RecordLoginAsync(sessionService, http, signedInUserId, ct); - var returnUrl = ExtractRelayStateReturnUrl(binding); return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); } diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index 040f8ba5..d4835ec4 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -96,6 +96,13 @@ public async Task> PatchAsync( doc.NativeGrants = r.Value; } + if (dto.ClientSessions is not null) + { + var r = MapClientSessions(dto.ClientSessions); + if (r.IsError) return r.FirstError; + doc.ClientSessions = r.Value; + } + if (dto.Dcr is not null) { var r = MapDcr(dto.Dcr); @@ -173,6 +180,9 @@ public async Task> StageNonOriginAsync( if (dto.NativeGrants is null) doc.NativeGrants = null; else { var r = MapNativeGrants(dto.NativeGrants); if (r.IsError) return r.FirstError; doc.NativeGrants = r.Value; } + if (dto.ClientSessions is null) doc.ClientSessions = null; + else { var r = MapClientSessions(dto.ClientSessions); if (r.IsError) return r.FirstError; doc.ClientSessions = r.Value; } + if (dto.Dcr is null) doc.Dcr = null; else { var r = MapDcr(dto.Dcr); if (r.IsError) return r.FirstError; doc.Dcr = r.Value; } @@ -308,6 +318,25 @@ private static ErrorOr MapNativeGrants(Applicat }; } + private static ErrorOr MapClientSessions(ApplicationClientSessionsDto dto) + { + if (dto.IdleLifetimeDays is { } idle && (idle < 1 || idle > 3650)) + return Error.Validation("ClientSessions.InvalidIdleLifetime", + "IdleLifetimeDays must be between 1 and 3650."); + if (dto.AbsoluteLifetimeDays is { } absolute && (absolute < 1 || absolute > 3650)) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be between 1 and 3650."); + if (dto.IdleLifetimeDays is { } i && dto.AbsoluteLifetimeDays is { } a && a < i) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be at least IdleLifetimeDays."); + + return new ApplicationClientSessionOverrides + { + IdleLifetime = Days(dto.IdleLifetimeDays), + AbsoluteLifetime = Days(dto.AbsoluteLifetimeDays), + }; + } + private static ErrorOr MapDcr(ApplicationDcrDto d) { if (LifetimeError("Dcr", d.AccessTokenLifetimeMinutes, d.RefreshTokenLifetimeDays) is { } e) return e; @@ -438,6 +467,11 @@ internal static ApplicationSettingsDto ToDto(ApplicationSettings? doc) AccessTokenLifetimeMinutes = doc.NativeGrants.AccessTokenLifetime is { } na ? (int)na.TotalMinutes : null, RefreshTokenLifetimeDays = doc.NativeGrants.RefreshTokenLifetime is { } nr ? (int)nr.TotalDays : null, }, + ClientSessions = doc.ClientSessions is null ? null : new ApplicationClientSessionsDto + { + IdleLifetimeDays = doc.ClientSessions.IdleLifetime is { } idle ? (int)idle.TotalDays : null, + AbsoluteLifetimeDays = doc.ClientSessions.AbsoluteLifetime is { } absolute ? (int)absolute.TotalDays : null, + }, Dcr = doc.Dcr is null ? null : new ApplicationDcrDto { Enabled = doc.Dcr.Enabled, diff --git a/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs b/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs new file mode 100644 index 00000000..2131a225 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Domain/ClientSession.cs @@ -0,0 +1,36 @@ +namespace Modgud.Authentication.Domain; + +/// +/// Authoritative server-side continuation state for one native OAuth +/// client/device. The associated refresh-token family is rooted in a unique +/// OpenIddict authorization so this row can be revoked independently. +/// +public class ClientSession +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string ClientId { get; set; } = string.Empty; + public string OAuthApplicationId { get; set; } = string.Empty; + public string AuthorizationId { get; set; } = string.Empty; + public string? ClientDisplayName { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public string? Browser { get; set; } + public string? BrowserVersion { get; set; } + public string? OperatingSystem { get; set; } + public string? OsVersion { get; set; } + public string? DeviceType { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastActiveAt { get; set; } + public DateTimeOffset AbsoluteExpiresAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + + public bool IsActive(DateTimeOffset now) => ExpiresAt > now && AbsoluteExpiresAt > now; + + public void Touch(DateTimeOffset now, TimeSpan idleLifetime) + { + LastActiveAt = now; + var idleExpiry = now.Add(idleLifetime); + ExpiresAt = idleExpiry <= AbsoluteExpiresAt ? idleExpiry : AbsoluteExpiresAt; + } +} diff --git a/src/dotnet/Modgud.Authentication/Domain/UserSession.cs b/src/dotnet/Modgud.Authentication/Domain/UserSession.cs index 8ff9b1a6..f81e8c88 100644 --- a/src/dotnet/Modgud.Authentication/Domain/UserSession.cs +++ b/src/dotnet/Modgud.Authentication/Domain/UserSession.cs @@ -11,9 +11,6 @@ public class UserSession public Guid Id { get; set; } public Guid UserId { get; set; } - /// Optional correlation token (e.g. cookie/session id). - public string? SessionId { get; set; } - public string? IpAddress { get; set; } public string? UserAgent { get; set; } @@ -26,11 +23,11 @@ public class UserSession public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset LastActiveAt { get; set; } + public DateTimeOffset AbsoluteExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public static UserSession Create( Guid userId, - string? sessionId, string? ipAddress, string? userAgent, string? browser, @@ -38,14 +35,14 @@ public static UserSession Create( string? operatingSystem, string? osVersion, string? deviceType, - TimeSpan sessionDuration) + TimeSpan idleLifetime, + TimeSpan absoluteLifetime) { var now = DateTimeOffset.UtcNow; return new UserSession { Id = Guid.NewGuid(), UserId = userId, - SessionId = sessionId, IpAddress = ipAddress, UserAgent = userAgent, Browser = browser, @@ -55,9 +52,19 @@ public static UserSession Create( DeviceType = deviceType, CreatedAt = now, LastActiveAt = now, - ExpiresAt = now.Add(sessionDuration), + AbsoluteExpiresAt = now.Add(absoluteLifetime), + ExpiresAt = Min(now.Add(idleLifetime), now.Add(absoluteLifetime)), }; } - public void Touch() => LastActiveAt = DateTimeOffset.UtcNow; + public bool IsActive(DateTimeOffset now) => ExpiresAt > now && AbsoluteExpiresAt > now; + + public void Touch(DateTimeOffset now, TimeSpan idleLifetime) + { + LastActiveAt = now; + ExpiresAt = Min(now.Add(idleLifetime), AbsoluteExpiresAt); + } + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => + left <= right ? left : right; } diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs index e3417e0b..84a587c4 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprDtos.cs @@ -78,12 +78,17 @@ public record ExportSecurityDto public record ExportSessionDto { + public required string Kind { get; init; } + public string? ClientId { get; init; } + public string? ClientDisplayName { get; init; } public string? IpAddress { get; init; } public string? Browser { get; init; } public string? OperatingSystem { get; init; } public string? DeviceType { get; init; } public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset LastActiveAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset AbsoluteExpiresAt { get; init; } } public record ExportLoginEventDto diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs index 63c119c2..7ff43171 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs @@ -50,6 +50,9 @@ public async Task> ExportUserDataAsync(Guid userId, C var sessions = await session.Query() .Where(s => s.UserId == userId) .ToListAsync(ct); + var clientSessions = await session.Query() + .Where(s => s.UserId == userId) + .ToListAsync(ct); var loginHistory = await GetLoginHistoryAsync(userId, 100, ct); @@ -60,7 +63,7 @@ public async Task> ExportUserDataAsync(Guid userId, C Metadata = new ExportMetadataDto { ExportedAt = DateTimeOffset.UtcNow, - FormatVersion = "1.0", + FormatVersion = "1.1", UserId = userId, }, Profile = new ExportProfileDto @@ -84,13 +87,29 @@ public async Task> ExportUserDataAsync(Guid userId, C Permissions = permissions, Sessions = sessions.Select(s => new ExportSessionDto { + Kind = "Browser", + IpAddress = s.IpAddress, + Browser = s.Browser, + OperatingSystem = s.OperatingSystem, + DeviceType = s.DeviceType, + CreatedAt = s.CreatedAt, + LastActiveAt = s.LastActiveAt, + ExpiresAt = s.ExpiresAt, + AbsoluteExpiresAt = s.AbsoluteExpiresAt, + }).Concat(clientSessions.Select(s => new ExportSessionDto + { + Kind = "OAuthClient", + ClientId = s.ClientId, + ClientDisplayName = s.ClientDisplayName, IpAddress = s.IpAddress, Browser = s.Browser, OperatingSystem = s.OperatingSystem, DeviceType = s.DeviceType, CreatedAt = s.CreatedAt, LastActiveAt = s.LastActiveAt, - }).ToList(), + ExpiresAt = s.ExpiresAt, + AbsoluteExpiresAt = s.AbsoluteExpiresAt, + })).ToList(), LoginHistory = loginHistory, }; } @@ -259,6 +278,7 @@ private async Task> PerformPermanentEraseAsync(Guid userId, Guid? // 2) Drop secondary documents (sessions + security data + change requests). session.DeleteWhere(s => s.UserId == userId); + session.DeleteWhere(s => s.UserId == userId); session.Delete(userId); // Federation v1: the per-user external-claims snapshot is a plain diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index e30ccb22..896b78f2 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -79,6 +79,20 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d doc.NativeGrants = native.Value; } + if (dto.BrowserSessions is not null) + { + var browserSessions = ApplyBrowserSessionPatch(doc.BrowserSessions, dto.BrowserSessions); + if (browserSessions.IsError) return browserSessions.FirstError; + doc.BrowserSessions = browserSessions.Value; + } + + if (dto.ClientSessions is not null) + { + var clientSessions = ApplyClientSessionPatch(doc.ClientSessions, dto.ClientSessions); + if (clientSessions.IsError) return clientSessions.FirstError; + doc.ClientSessions = clientSessions.Value; + } + if (dto.AuthRateLimits is not null) { var arl = ApplyAuthRateLimitsPatch(doc.AuthRateLimits, dto.AuthRateLimits); @@ -153,6 +167,8 @@ private SelfRegistrationSettings ApplySelfRegistrationPatch( Dcr = MapDcrToDto(doc.Dcr), Cimd = MapCimdToDto(doc.Cimd), NativeGrants = MapNativeGrantsToDto(doc.NativeGrants), + BrowserSessions = MapBrowserSessionsToDto(doc.BrowserSessions), + ClientSessions = MapClientSessionsToDto(doc.ClientSessions), AuthRateLimits = MapAuthRateLimitsToDto(doc.AuthRateLimits), Branding = MapBrandingToDto(doc.Branding), RegistrationFields = MapRegistrationFieldsToDto(doc.RegistrationFields), @@ -507,6 +523,61 @@ private static ErrorOr ApplyNativeGrantsPatch(NativeGrantSe return merged; } + private static ErrorOr ApplyBrowserSessionPatch( + BrowserSessionPolicy? current, + UpdateBrowserSessionPolicyDto patch) + { + var policy = current ?? BrowserSessionPolicy.Defaults; + var merged = policy with + { + IdleLifetime = patch.IdleLifetimeMinutes is { } idle + ? TimeSpan.FromMinutes(idle) + : policy.IdleLifetime, + AbsoluteLifetime = patch.AbsoluteLifetimeMinutes is { } absolute + ? TimeSpan.FromMinutes(absolute) + : policy.AbsoluteLifetime, + AllowRememberMe = patch.AllowRememberMe ?? policy.AllowRememberMe, + }; + + if (merged.IdleLifetime < TimeSpan.FromMinutes(5) || + merged.IdleLifetime > TimeSpan.FromDays(365)) + return Error.Validation("BrowserSessions.InvalidIdleLifetime", + "IdleLifetimeMinutes must be between 5 minutes and 365 days."); + if (merged.AbsoluteLifetime < merged.IdleLifetime || + merged.AbsoluteLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("BrowserSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeMinutes must be at least the idle lifetime and no more than 3650 days."); + + return merged; + } + + private static ErrorOr ApplyClientSessionPatch( + ClientSessionPolicy? current, + UpdateClientSessionPolicyDto patch) + { + var policy = current ?? ClientSessionPolicy.Defaults; + var merged = policy with + { + IdleLifetime = patch.IdleLifetimeDays is { } idle + ? TimeSpan.FromDays(idle) + : policy.IdleLifetime, + AbsoluteLifetime = patch.AbsoluteLifetimeDays is { } absolute + ? TimeSpan.FromDays(absolute) + : policy.AbsoluteLifetime, + }; + + if (merged.IdleLifetime < TimeSpan.FromDays(1) || + merged.IdleLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("ClientSessions.InvalidIdleLifetime", + "IdleLifetimeDays must be between 1 and 3650."); + if (merged.AbsoluteLifetime < merged.IdleLifetime || + merged.AbsoluteLifetime > TimeSpan.FromDays(3650)) + return Error.Validation("ClientSessions.InvalidAbsoluteLifetime", + "AbsoluteLifetimeDays must be at least the idle lifetime and no more than 3650."); + + return merged; + } + internal static NativeGrantSettingsDto MapNativeGrantsToDto(NativeGrantSettings? s) { // Source the never-configured display defaults from the domain record so @@ -520,6 +591,27 @@ internal static NativeGrantSettingsDto MapNativeGrantsToDto(NativeGrantSettings? }; } + internal static BrowserSessionPolicyDto MapBrowserSessionsToDto(BrowserSessionPolicy? policy) + { + policy ??= BrowserSessionPolicy.Defaults; + return new BrowserSessionPolicyDto + { + IdleLifetimeMinutes = checked((int)policy.IdleLifetime.TotalMinutes), + AbsoluteLifetimeMinutes = checked((int)policy.AbsoluteLifetime.TotalMinutes), + AllowRememberMe = policy.AllowRememberMe, + }; + } + + internal static ClientSessionPolicyDto MapClientSessionsToDto(ClientSessionPolicy? policy) + { + policy ??= ClientSessionPolicy.Defaults; + return new ClientSessionPolicyDto + { + IdleLifetimeDays = checked((int)policy.IdleLifetime.TotalDays), + AbsoluteLifetimeDays = checked((int)policy.AbsoluteLifetime.TotalDays), + }; + } + internal static SelfRegistrationDto MapSelfRegistrationToDto(SelfRegistrationSettings? s) { if (s is null) return new SelfRegistrationDto(); diff --git a/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs new file mode 100644 index 00000000..9c3e3800 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionConnectionRegistry.cs @@ -0,0 +1,58 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; + +namespace Modgud.Authentication.Sessions; + +public interface IBrowserSessionConnectionRegistry +{ + IDisposable Register(Guid sessionId, string connectionId, HttpContext httpContext); + void Revoke(Guid sessionId); +} + +/// +/// Process-local registry used to abort already-upgraded SignalR connections +/// immediately when their authoritative browser session is revoked. In a +/// multi-node deployment the normal per-invocation validation remains the +/// cross-node backstop; a distributed disconnect notification can be added with +/// the SignalR backplane. +/// +public sealed class BrowserSessionConnectionRegistry : IBrowserSessionConnectionRegistry +{ + private readonly ConcurrentDictionary> _connections = new(); + + public IDisposable Register(Guid sessionId, string connectionId, HttpContext httpContext) + { + var perSession = _connections.GetOrAdd(sessionId, _ => new()); + perSession[connectionId] = httpContext; + return new Registration(this, sessionId, connectionId); + } + + public void Revoke(Guid sessionId) + { + if (!_connections.TryRemove(sessionId, out var connections)) return; + foreach (var http in connections.Values) + http.Abort(); + } + + private void Remove(Guid sessionId, string connectionId) + { + if (!_connections.TryGetValue(sessionId, out var connections)) return; + connections.TryRemove(connectionId, out _); + if (connections.IsEmpty) + _connections.TryRemove(new KeyValuePair>(sessionId, connections)); + } + + private sealed class Registration( + BrowserSessionConnectionRegistry owner, + Guid sessionId, + string connectionId) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + owner.Remove(sessionId, connectionId); + } + } +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs new file mode 100644 index 00000000..1c85e222 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/BrowserSessionCookieEvents.cs @@ -0,0 +1,119 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Modgud.Authentication.Domain; + +namespace Modgud.Authentication.Sessions; + +/// +/// Makes authoritative for every Modgud application +/// cookie, including login paths that bypass SignInManager. +/// +public sealed class BrowserSessionCookieEvents(ISessionService sessions) : CookieAuthenticationEvents +{ + public override async Task SigningIn(CookieSigningInContext context) + { + var principal = context.Principal; + var userId = ParseUserId(principal); + if (principal is null || userId is null) + throw new InvalidOperationException("An application cookie cannot be issued without a user id."); + + UserSession? browserSession = null; + // RefreshSignInAsync rebuilds the principal and may drop custom claims. + // Fall back to the currently authenticated request so a profile/stamp + // refresh keeps the same authoritative session instead of creating a + // duplicate row. + var currentClaim = principal.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value + ?? context.HttpContext.User.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (Guid.TryParse(currentClaim, out var currentSessionId)) + browserSession = await sessions.ValidateSessionAsync( + userId.Value, currentSessionId, touch: false, context.HttpContext.RequestAborted); + + if (browserSession is null) + { + var created = await sessions.CreateSessionAsync( + userId.Value, + context.HttpContext.Connection.RemoteIpAddress?.ToString(), + context.HttpContext.Request.Headers.UserAgent.ToString(), + context.HttpContext.RequestAborted); + if (created.IsError) + throw new InvalidOperationException(created.FirstError.Description); + browserSession = created.Value; + } + + var identity = principal.Identities.FirstOrDefault(i => i.IsAuthenticated) + ?? throw new InvalidOperationException("An application cookie requires an authenticated identity."); + foreach (var old in principal.FindAll(SessionClaimTypes.BrowserSessionId).ToList()) + old.Subject?.RemoveClaim(old); + identity.AddClaim(new Claim(SessionClaimTypes.BrowserSessionId, browserSession.Id.ToString())); + + var policy = await sessions.GetPolicyAsync(context.HttpContext.RequestAborted); + if (!policy.AllowRememberMe) + context.Properties.IsPersistent = false; + context.Properties.IssuedUtc = DateTimeOffset.UtcNow; + context.Properties.ExpiresUtc = browserSession.ExpiresAt; + } + + public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + var userId = ParseUserId(context.Principal); + var rawSessionId = context.Principal?.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is null || !Guid.TryParse(rawSessionId, out var sessionId)) + { + await RejectAsync(context); + return; + } + + // Storage exceptions intentionally escape: a transient database outage + // fails the request but does not turn into a destructive cookie delete. + var session = await sessions.ValidateSessionAsync( + userId.Value, sessionId, touch: true, context.HttpContext.RequestAborted); + if (session is null) + { + await RejectAsync(context); + return; + } + + await SecurityStampValidator.ValidatePrincipalAsync(context); + } + + public override async Task SigningOut(CookieSigningOutContext context) + { + var principal = context.HttpContext.User; + var userId = ParseUserId(principal); + var rawSessionId = principal.FindFirst(SessionClaimTypes.BrowserSessionId)?.Value; + if (userId is not null && Guid.TryParse(rawSessionId, out var sessionId)) + await sessions.RevokeSessionAsync( + userId.Value, sessionId, context.HttpContext.RequestAborted); + } + + public override Task RedirectToLogin(RedirectContext context) => + RedirectOrStatusAsync(context, StatusCodes.Status401Unauthorized); + + public override Task RedirectToAccessDenied(RedirectContext context) => + RedirectOrStatusAsync(context, StatusCodes.Status403Forbidden); + + private static Guid? ParseUserId(ClaimsPrincipal? principal) + { + var raw = principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(raw, out var id) ? id : null; + } + + private static async Task RejectAsync(CookieValidatePrincipalContext context) + { + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme); + } + + private static Task RedirectOrStatusAsync( + RedirectContext context, + int apiStatusCode) + { + if (context.Request.Path.StartsWithSegments("/api")) + context.Response.StatusCode = apiStatusCode; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + } +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs new file mode 100644 index 00000000..6208fbe9 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/ClientSessionService.cs @@ -0,0 +1,254 @@ +using System.Globalization; +using ErrorOr; +using Marten; +using JasperFx; +using Modgud.Authentication.Applications; +using Modgud.Authentication.Domain; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.Applications; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.OpenIddict; + +namespace Modgud.Authentication.Sessions; + +public sealed class ClientSessionService( + IDocumentSession session, + IDeviceInfoService deviceInfo, + IRealmSettingsService realmSettings, + IOAuthGrantRevoker grants) : IClientSessionService, IRefreshTokenReuseObserver +{ + private static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(5); + + public async Task ResolvePolicyAsync(string clientId, CancellationToken ct = default) + { + var realm = await realmSettings.LoadAsync(ct); + var realmPolicy = realm.ClientSessions ?? ClientSessionPolicy.Defaults; + var effective = realmPolicy; + + var client = await session.Query() + .FirstOrDefaultAsync(x => x.ClientId == clientId && !x.IsDeleted, ct); + if (client is not null && client.AppIds.Count > 0) + { + var appPolicies = new List(); + foreach (var appId in client.AppIds.Distinct()) + { + var app = await session.LoadAsync(appId, ct); + var overrides = app?.ClientSessions; + appPolicies.Add(overrides is null + ? realmPolicy + : realmPolicy with + { + IdleLifetime = overrides.IdleLifetime ?? realmPolicy.IdleLifetime, + AbsoluteLifetime = overrides.AbsoluteLifetime ?? realmPolicy.AbsoluteLifetime, + }); + } + + // A multi-App client inherits the strictest participating App until + // an explicit client override removes the ambiguity. + if (appPolicies.Count > 0) + { + effective = new ClientSessionPolicy + { + IdleLifetime = appPolicies.Min(x => x.IdleLifetime), + AbsoluteLifetime = appPolicies.Min(x => x.AbsoluteLifetime), + }; + } + } + + if (client is not null) + { + effective = effective with + { + IdleLifetime = ReadSeconds(client.Settings, OAuthApplicationSettingKeys.ClientSessionIdleLifetime) + ?? ReadSeconds(client.Settings, OAuthApplicationSettingKeys.SlidingRefreshTokenLifetime) + ?? effective.IdleLifetime, + AbsoluteLifetime = ReadSeconds(client.Settings, OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime) + ?? effective.AbsoluteLifetime, + }; + } + + var max = TimeSpan.FromDays(3650); + var absolute = Clamp(effective.AbsoluteLifetime, TimeSpan.FromDays(1), max); + var idle = Clamp(effective.IdleLifetime, TimeSpan.FromDays(1), absolute); + return new ClientSessionPolicy { IdleLifetime = idle, AbsoluteLifetime = absolute }; + } + + public async Task CreateAsync(CreateClientSessionRequest request, CancellationToken ct = default) + { + var policy = await ResolvePolicyAsync(request.ClientId, ct); + var device = deviceInfo.Parse(); + var now = DateTimeOffset.UtcNow; + var entity = new ClientSession + { + Id = Guid.CreateVersion7(), + UserId = request.UserId, + ClientId = request.ClientId, + OAuthApplicationId = request.OAuthApplicationId, + AuthorizationId = request.AuthorizationId, + ClientDisplayName = request.ClientDisplayName, + IpAddress = request.IpAddress, + UserAgent = request.UserAgent, + Browser = device.Browser, + BrowserVersion = device.BrowserVersion, + OperatingSystem = device.OperatingSystem, + OsVersion = device.OsVersion, + DeviceType = device.DeviceType, + CreatedAt = now, + LastActiveAt = now, + AbsoluteExpiresAt = now.Add(policy.AbsoluteLifetime), + }; + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + await session.SaveChangesAsync(ct); + return entity; + } + + public async Task ValidateAndTouchAsync( + Guid userId, + Guid clientSessionId, + string clientId, + string? authorizationId, + CancellationToken ct = default) + { + var entity = await session.LoadAsync(clientSessionId, ct); + var now = DateTimeOffset.UtcNow; + if (entity is null || + entity.UserId != userId || + !string.Equals(entity.ClientId, clientId, StringComparison.Ordinal) || + string.IsNullOrEmpty(authorizationId) || + !string.Equals(entity.AuthorizationId, authorizationId, StringComparison.Ordinal)) + return null; + + if (!entity.IsActive(now)) + { + await RevokeCoreAsync(entity, ct); + return null; + } + + if (entity.LastActiveAt <= now.Subtract(TouchInterval)) + { + var policy = await ResolvePolicyAsync(clientId, ct); + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + return null; + } + } + + return entity; + } + + public async Task> GetSessionsAsync(Guid userId, CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var rows = await session.Query() + .Where(x => x.UserId == userId && x.ExpiresAt > now && x.AbsoluteExpiresAt > now) + .OrderByDescending(x => x.LastActiveAt) + .ToListAsync(ct); + return rows.Select(ToDto).ToList(); + } + + public async Task> RevokeAsync(Guid userId, Guid sessionId, CancellationToken ct = default) + { + var entity = await session.LoadAsync(sessionId, ct); + if (entity is null) + return Error.NotFound("ClientSession.NotFound", $"Client session {sessionId} not found."); + if (entity.UserId != userId) + return Error.Forbidden("ClientSession.NotOwner", "Caller does not own this client session."); + await RevokeCoreAsync(entity, ct); + return true; + } + + public async Task RevokeAllAsync(Guid userId, bool revokeGrants, CancellationToken ct = default) + { + var rows = await session.Query().Where(x => x.UserId == userId).ToListAsync(ct); + if (revokeGrants) + { + foreach (var row in rows) + { + await grants.RevokeTokensByAuthorizationIdAsync(row.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(row.AuthorizationId, ct); + } + } + + session.DeleteWhere(x => x.UserId == userId); + await session.SaveChangesAsync(ct); + } + + public async Task PruneExpiredAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var rows = await session.Query() + .Where(x => x.ExpiresAt <= now || x.AbsoluteExpiresAt <= now) + .ToListAsync(ct); + foreach (var row in rows) + { + await grants.RevokeTokensByAuthorizationIdAsync(row.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(row.AuthorizationId, ct); + session.Delete(row); + } + if (rows.Count > 0) + await session.SaveChangesAsync(ct); + return rows.Count; + } + + public async Task OnReuseDetectedAsync( + string? subject, + string? clientId, + string? authorizationId, + CancellationToken ct) + { + if (string.IsNullOrEmpty(authorizationId)) return; + + var rows = await session.Query() + .Where(x => x.AuthorizationId == authorizationId) + .ToListAsync(ct); + if (rows.Count == 0) return; + + foreach (var row in rows) + session.Delete(row); + await session.SaveChangesAsync(ct); + } + + private async Task RevokeCoreAsync(ClientSession entity, CancellationToken ct) + { + await grants.RevokeTokensByAuthorizationIdAsync(entity.AuthorizationId, ct); + await grants.RevokeAuthorizationByIdAsync(entity.AuthorizationId, ct); + session.Delete(entity); + await session.SaveChangesAsync(ct); + } + + private static ClientSessionDto ToDto(ClientSession x) => new() + { + Id = x.Id.ToString(), + ClientId = x.ClientId, + ClientDisplayName = x.ClientDisplayName, + IpAddress = x.IpAddress, + Browser = x.Browser, + BrowserVersion = x.BrowserVersion, + OperatingSystem = x.OperatingSystem, + OsVersion = x.OsVersion, + DeviceType = x.DeviceType, + CreatedAt = x.CreatedAt, + LastActiveAt = x.LastActiveAt, + ExpiresAt = x.ExpiresAt, + AbsoluteExpiresAt = x.AbsoluteExpiresAt, + }; + + private static TimeSpan? ReadSeconds(IReadOnlyDictionary settings, string key) + { + if (!settings.TryGetValue(key, out var raw) || + !int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + return null; + return TimeSpan.FromSeconds(seconds); + } + + private static TimeSpan Clamp(TimeSpan value, TimeSpan min, TimeSpan max) => + value < min ? min : value > max ? max : value; +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs new file mode 100644 index 00000000..e8217325 --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/IClientSessionService.cs @@ -0,0 +1,30 @@ +using ErrorOr; +using Modgud.Authentication.Domain; +using Modgud.Domain.Realms; + +namespace Modgud.Authentication.Sessions; + +public sealed record CreateClientSessionRequest( + Guid UserId, + string ClientId, + string OAuthApplicationId, + string AuthorizationId, + string? ClientDisplayName, + string? IpAddress, + string? UserAgent); + +public interface IClientSessionService +{ + Task ResolvePolicyAsync(string clientId, CancellationToken ct = default); + Task CreateAsync(CreateClientSessionRequest request, CancellationToken ct = default); + Task ValidateAndTouchAsync( + Guid userId, + Guid clientSessionId, + string clientId, + string? authorizationId, + CancellationToken ct = default); + Task> GetSessionsAsync(Guid userId, CancellationToken ct = default); + Task> RevokeAsync(Guid userId, Guid sessionId, CancellationToken ct = default); + Task RevokeAllAsync(Guid userId, bool revokeGrants, CancellationToken ct = default); + Task PruneExpiredAsync(CancellationToken ct = default); +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs index 79f6f76a..d167fc45 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/ISessionService.cs @@ -1,5 +1,6 @@ using Modgud.Authentication.Domain; using ErrorOr; +using Modgud.Domain.Realms; namespace Modgud.Authentication.Sessions; @@ -14,6 +15,12 @@ public interface ISessionService /// Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default); + Task GetPolicyAsync(CancellationToken ct = default); + + /// Loads and validates the authoritative session. Successful + /// validation also performs a throttled sliding-idle touch. + Task ValidateSessionAsync(Guid userId, Guid sessionId, bool touch, CancellationToken ct = default); + /// Revokes a single session owned by the caller. Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default); @@ -25,4 +32,6 @@ public interface ISessionService /// Updates the last-active timestamp. Task TouchSessionAsync(Guid sessionId, CancellationToken ct = default); + + Task PruneExpiredAsync(CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs new file mode 100644 index 00000000..2444762b --- /dev/null +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionClaimTypes.cs @@ -0,0 +1,7 @@ +namespace Modgud.Authentication.Sessions; + +public static class SessionClaimTypes +{ + public const string BrowserSessionId = "modgud.session_id"; + public const string ClientSessionId = "modgud.client_session_id"; +} diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs index 7e4d701d..eaf0e50b 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionDtos.cs @@ -17,4 +17,22 @@ public record SessionDto public record SessionListDto { public required List Sessions { get; init; } + public List ClientSessions { get; init; } = []; +} + +public record ClientSessionDto +{ + public required string Id { get; init; } + public required string ClientId { get; init; } + public string? ClientDisplayName { get; init; } + public string? IpAddress { get; init; } + public string? Browser { get; init; } + public string? BrowserVersion { get; init; } + public string? OperatingSystem { get; init; } + public string? OsVersion { get; init; } + public string? DeviceType { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastActiveAt { get; init; } + public DateTimeOffset ExpiresAt { get; init; } + public DateTimeOffset AbsoluteExpiresAt { get; init; } } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs index 760f0b4a..356edcc1 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/SessionService.cs @@ -1,6 +1,9 @@ using Modgud.Authentication.Domain; using ErrorOr; using Marten; +using JasperFx; +using Modgud.Authentication.RealmSettings; +using Modgud.Domain.Realms; namespace Modgud.Authentication.Sessions; @@ -10,15 +13,19 @@ namespace Modgud.Authentication.Sessions; /// via TenantedSessionFactory, so a user's sessions never leak across /// realms. /// -public class SessionService(IDocumentSession session, IDeviceInfoService deviceInfo) : ISessionService +public class SessionService( + IDocumentSession session, + IDeviceInfoService deviceInfo, + IRealmSettingsService realmSettings, + IBrowserSessionConnectionRegistry connections) : ISessionService { - private static readonly TimeSpan DefaultSessionDuration = TimeSpan.FromDays(14); + private static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(5); public async Task> GetSessionsAsync(Guid userId, Guid? currentSessionId, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; var sessions = await session.Query() - .Where(s => s.UserId == userId && s.ExpiresAt > now) + .Where(s => s.UserId == userId && s.ExpiresAt > now && s.AbsoluteExpiresAt > now) .OrderByDescending(s => s.LastActiveAt) .ToListAsync(ct); @@ -41,10 +48,10 @@ public async Task> GetSessionsAsync(Guid userId, Guid? c public async Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default) { + var policy = await GetPolicyAsync(ct); var device = deviceInfo.Parse(); var entity = UserSession.Create( userId, - sessionId: Guid.NewGuid().ToString(), ipAddress, userAgent, device.Browser, @@ -52,13 +59,56 @@ public async Task> CreateSessionAsync(Guid userId, string? device.OperatingSystem, device.OsVersion, device.DeviceType, - DefaultSessionDuration); + policy.IdleLifetime, + policy.AbsoluteLifetime); session.Store(entity); await session.SaveChangesAsync(ct); return entity; } + public async Task GetPolicyAsync(CancellationToken ct = default) + { + var settings = await realmSettings.LoadAsync(ct); + return settings.BrowserSessions ?? BrowserSessionPolicy.Defaults; + } + + public async Task ValidateSessionAsync( + Guid userId, + Guid sessionId, + bool touch, + CancellationToken ct = default) + { + var entity = await session.LoadAsync(sessionId, ct); + var now = DateTimeOffset.UtcNow; + if (entity is null || entity.UserId != userId) return null; + if (!entity.IsActive(now)) + { + session.Delete(entity); + await session.SaveChangesAsync(ct); + connections.Revoke(sessionId); + return null; + } + + if (touch && entity.LastActiveAt <= now.Subtract(TouchInterval)) + { + var policy = await GetPolicyAsync(ct); + entity.Touch(now, policy.IdleLifetime); + session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + // A concurrent targeted revoke wins. Never re-insert a deleted row. + return null; + } + } + + return entity; + } + public async Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) { var entity = await session.LoadAsync(sessionId, ct); @@ -67,17 +117,29 @@ public async Task> RevokeSessionAsync(Guid userId, Guid sessionId, session.Delete(sessionId); await session.SaveChangesAsync(ct); + connections.Revoke(sessionId); return true; } public async Task> RevokeAllSessionsAsync(Guid userId, Guid? exceptSessionId, CancellationToken ct = default) { - if (exceptSessionId.HasValue) - session.DeleteWhere(s => s.UserId == userId && s.Id != exceptSessionId.Value); + var ids = exceptSessionId is { } excludedId + ? await session.Query() + .Where(s => s.UserId == userId && s.Id != excludedId) + .Select(s => s.Id) + .ToListAsync(ct) + : await session.Query() + .Where(s => s.UserId == userId) + .Select(s => s.Id) + .ToListAsync(ct); + + if (exceptSessionId is { } excludedSessionId) + session.DeleteWhere(s => s.UserId == userId && s.Id != excludedSessionId); else session.DeleteWhere(s => s.UserId == userId); await session.SaveChangesAsync(ct); + foreach (var id in ids) connections.Revoke(id); return true; } @@ -85,8 +147,31 @@ public async Task TouchSessionAsync(Guid sessionId, CancellationToken ct = defau { var entity = await session.LoadAsync(sessionId, ct); if (entity is null) return; - entity.Touch(); + var policy = await GetPolicyAsync(ct); + entity.Touch(DateTimeOffset.UtcNow, policy.IdleLifetime); session.Store(entity); + try + { + await session.SaveChangesAsync(ct); + } + catch (ConcurrencyException) + { + // Revocation won the race. + } + } + + public async Task PruneExpiredAsync(CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + var expired = await session.Query() + .Where(s => s.ExpiresAt <= now || s.AbsoluteExpiresAt <= now) + .Select(s => s.Id) + .ToListAsync(ct); + if (expired.Count == 0) return 0; + + session.DeleteWhere(s => s.ExpiresAt <= now || s.AbsoluteExpiresAt <= now); await session.SaveChangesAsync(ct); + foreach (var id in expired) connections.Revoke(id); + return expired.Count; } } diff --git a/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs b/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs deleted file mode 100644 index 9a369a13..00000000 --- a/src/dotnet/Modgud.Authentication/Sessions/SessionTracker.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Modgud.Authentication.Sessions; - -/// -/// Convenience helper for sign-in handlers — captures IP + UA from the -/// current and persists a session row. Failures -/// are swallowed (logged would be added once Serilog wiring is consistent -/// across slices) so a session-tracking blip never breaks login. -/// -public static class SessionTracker -{ - public static async Task RecordLoginAsync( - ISessionService sessions, - HttpContext httpContext, - Guid userId, - CancellationToken ct = default) - { - try - { - var ip = httpContext.Connection.RemoteIpAddress?.ToString(); - var ua = httpContext.Request.Headers.UserAgent.ToString(); - await sessions.CreateSessionAsync(userId, ip, ua, ct); - } - catch - { - // Swallow — session tracking is best-effort. - } - } -} diff --git a/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs b/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs index 46968337..157114b1 100644 --- a/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs +++ b/src/dotnet/Modgud.Authentication/Sessions/UserAccessRevoker.cs @@ -15,6 +15,7 @@ namespace Modgud.Authentication.Sessions; public sealed class UserAccessRevoker( UserManager userManager, ISessionService sessionService, + IClientSessionService clientSessionService, IOAuthGrantRevoker grantRevoker, ILogger logger) : IUserAccessRevoker { @@ -36,6 +37,9 @@ public async Task RevokeAllAccessAsync(Guid userId, AccessRevocationReason reaso // 2) Device-session rows (clean device list / GDPR scrub). await sessionService.RevokeAllSessionsAsync(userId, exceptSessionId: null, ct); + // Token revocation above already cut every OAuth grant; this removes the + // user-facing native app/device rows without repeating the sweep. + await clientSessionService.RevokeAllAsync(userId, revokeGrants: false, ct); // 3) Rotate the security stamp → existing auth cookies fail at the next // SecurityStampValidator pass (<=5 min) and refresh grants fail the diff --git a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs index 1f340674..67ce7cc3 100644 --- a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs +++ b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs @@ -115,8 +115,19 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options) // force-logout flow. options.Schema.For() .Identity(x => x.Id) + .UseOptimisticConcurrency(true) .Index(x => x.UserId) - .Index(x => x.ExpiresAt); + .Index(x => x.ExpiresAt) + .Index(x => x.AbsoluteExpiresAt); + + options.Schema.For() + .Identity(x => x.Id) + .UseOptimisticConcurrency(true) + .Index(x => x.UserId) + .Index(x => x.ClientId) + .Index(x => x.AuthorizationId) + .Index(x => x.ExpiresAt) + .Index(x => x.AbsoluteExpiresAt); // WebAuthn/passkey credentials (raw crypto, not event-sourced). One per // enrolled authenticator; indexed by UserId for the per-user list/login diff --git a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs index b562fe04..a5ce5fae 100644 --- a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs @@ -58,6 +58,10 @@ public class ApplicationSettings /// inherit the realm native-grant settings. public ApplicationNativeGrantOverrides? NativeGrants { get; set; } + /// Per-Application defaults for native OAuth client/device + /// sessions. A concrete OAuth client may override these values. + public ApplicationClientSessionOverrides? ClientSessions { get; set; } + /// Per-Application Dynamic Client Registration overrides, merged /// field-by-field over the realm . Null = inherit. public ApplicationDcrOverrides? Dcr { get; set; } @@ -149,6 +153,14 @@ public record ApplicationNativeGrantOverrides public TimeSpan? RefreshTokenLifetime { get; init; } } +/// Nullable-field mirror of . Null +/// fields inherit the realm policy. +public record ApplicationClientSessionOverrides +{ + public TimeSpan? IdleLifetime { get; init; } + public TimeSpan? AbsoluteLifetime { get; init; } +} + /// Nullable-field mirror of . A null field /// inherits the realm value. (GcTtlDays is read by the realm-iterating GC job, /// not the per-request registration endpoint, so it stays effectively realm-level diff --git a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs index 0f71c515..6b1205b4 100644 --- a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs @@ -25,6 +25,7 @@ public sealed record EffectiveSettings public DcrSettings? Dcr { get; init; } public CimdSettings? Cimd { get; init; } public NativeGrantSettings? NativeGrants { get; init; } + public ClientSessionPolicy? ClientSessions { get; init; } public BrandingSettings? Branding { get; init; } public RegistrationFieldsSettings? RegistrationFields { get; init; } public DeletionSettings? Deletion { get; init; } @@ -50,6 +51,7 @@ public sealed record EffectiveSettings Dcr = realm.Dcr, Cimd = realm.Cimd, NativeGrants = realm.NativeGrants, + ClientSessions = realm.ClientSessions, Branding = realm.Branding, RegistrationFields = realm.RegistrationFields, Deletion = realm.Deletion, @@ -68,6 +70,7 @@ public sealed record EffectiveSettings { // Sections the App can override (field-by-field): NativeGrants = MergeNativeGrants(realm.NativeGrants, app.NativeGrants), + ClientSessions = MergeClientSessions(realm.ClientSessions, app.ClientSessions), Branding = MergeBranding(realm.Branding, app.Branding), SelfRegistration = MergeSelfRegistration(realm.SelfRegistration, app.SelfRegistration), Dcr = MergeDcr(realm.Dcr, app.Dcr), @@ -118,6 +121,19 @@ public sealed record EffectiveSettings }; } + private static ClientSessionPolicy? MergeClientSessions( + ClientSessionPolicy? realm, + ApplicationClientSessionOverrides? app) + { + if (app is null) return realm; + var policy = realm ?? ClientSessionPolicy.Defaults; + return policy with + { + IdleLifetime = app.IdleLifetime ?? policy.IdleLifetime, + AbsoluteLifetime = app.AbsoluteLifetime ?? policy.AbsoluteLifetime, + }; + } + // App override absent → realm passthrough. Present → each field is the App // value when set, else the realm value. Captcha fields stay realm-level (the // App override type doesn't carry them). diff --git a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs index f4c7c4a4..78f8b9c2 100644 --- a/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs +++ b/src/dotnet/Modgud.Domain/OAuth/Applications/OAuthApplicationKeys.cs @@ -8,6 +8,8 @@ public static class OAuthApplicationSettingKeys public const string AccessTokenLifetime = "modgud:access_token_lifetime"; public const string AuthorizationCodeLifetime = "modgud:authorization_code_lifetime"; public const string SlidingRefreshTokenLifetime = "modgud:sliding_refresh_token_lifetime"; + public const string ClientSessionIdleLifetime = "modgud:client_session_idle_lifetime"; + public const string ClientSessionAbsoluteLifetime = "modgud:client_session_absolute_lifetime"; public const string ClientClaimsPrefix = "modgud:client_claims_prefix"; /// diff --git a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs index c7c3a792..b6d78333 100644 --- a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs +++ b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs @@ -59,6 +59,15 @@ public class RealmSettings /// additional, separate gate. public NativeGrantSettings? NativeGrants { get; set; } + /// Realm-wide policy for the shared Modgud browser/SSO session. + /// Null = . + public BrowserSessionPolicy? BrowserSessions { get; set; } + + /// Realm fallback for native OAuth client/device sessions. + /// Applications and concrete OAuth clients may override it. Null = + /// . + public ClientSessionPolicy? ClientSessions { get; set; } + /// Per-realm overrides for the per-IP auth rate-limit ceilings /// (native-otp, magic-link, password-reset, email-otp, email-verification, /// passkey-begin, bootstrap). Null = never configured; every policy uses its diff --git a/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs b/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs new file mode 100644 index 00000000..e4fd01eb --- /dev/null +++ b/src/dotnet/Modgud.Domain/Realms/SessionPolicies.cs @@ -0,0 +1,32 @@ +namespace Modgud.Domain.Realms; + +/// +/// Realm-owned policy for the shared Modgud browser/SSO cookie. The cookie and +/// its authoritative UserSession row consume the same values. +/// +public record BrowserSessionPolicy +{ + public static BrowserSessionPolicy Defaults { get; } = new(); + + /// Sliding inactivity window. Default preserves the former 30-day cookie window. + public TimeSpan IdleLifetime { get; init; } = TimeSpan.FromDays(30); + + /// Hard limit measured from the interactive sign-in; activity never extends it. + public TimeSpan AbsoluteLifetime { get; init; } = TimeSpan.FromDays(180); + + /// Whether callers may request a browser-persistent cookie. + public bool AllowRememberMe { get; init; } = true; +} + +/// +/// Policy for a native OAuth client/device session. Access-token lifetime is +/// intentionally separate; this policy controls how long a rotating refresh +/// chain may continue without a full user sign-in. +/// +public record ClientSessionPolicy +{ + public static ClientSessionPolicy Defaults { get; } = new(); + + public TimeSpan IdleLifetime { get; init; } = TimeSpan.FromDays(30); + public TimeSpan AbsoluteLifetime { get; init; } = TimeSpan.FromDays(365); +} diff --git a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs index 1e46aef4..8b6cc19b 100644 --- a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs @@ -87,6 +87,15 @@ public static IServiceCollection AddInfrastructure( .Identity(x => x.Id) .Index(x => x.Slug, x => { x.IsUnique = true; x.Predicate = "((data ->> 'IsActive')::boolean = true)"; }); + // Deployment-wide scheduled jobs are controlled from whichever + // realm currently holds the Control-Plane role, but their config + // and history are platform data — never tenant/realm data. + opts.Schema.For() + .Identity(x => x.Key); + opts.Schema.For() + .Identity(x => x.Id) + .Index(x => new { x.JobKey, x.StartedAt }); + // RealmSigningKey lives in the per-tenant store (configured below), // not here. Defense-in-depth: a master-DB compromise must NOT leak // every realm's private signing key — the key for realm A only sits diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs index 371e66ab..343fc08f 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/IOAuthGrantRevoker.cs @@ -41,4 +41,12 @@ public interface IOAuthGrantRevoker /// must invalidate exactly that client's outstanding M2M tokens — narrower than /// a by-subject revoke, which would also kill the SA's other credentials. Task RevokeTokensByApplicationIdAsync(string applicationId, CancellationToken ct = default); + + /// Revoke the token family attached to one authorization/client + /// session without affecting another device using the same OAuth client. + Task RevokeTokensByAuthorizationIdAsync(string authorizationId, CancellationToken ct = default); + + /// Revoke one authorization used as the server-side root of a + /// native client/device session. + Task RevokeAuthorizationByIdAsync(string authorizationId, CancellationToken ct = default); } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs new file mode 100644 index 00000000..636fc6a0 --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/IRefreshTokenReuseObserver.cs @@ -0,0 +1,14 @@ +namespace Modgud.Infrastructure.OpenIddict; + +/// +/// Observes OpenIddict's confirmed refresh-token reuse signal before the +/// stock handler revokes the associated token family. +/// +public interface IRefreshTokenReuseObserver +{ + Task OnReuseDetectedAsync( + string? subject, + string? clientId, + string? authorizationId, + CancellationToken ct); +} diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs index dc9fab50..3e780204 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/OpenIddictGrantRevoker.cs @@ -52,4 +52,24 @@ public async Task RevokeTokensByApplicationIdAsync(string applicationId, Ca } return revoked; } + + public async Task RevokeTokensByAuthorizationIdAsync(string authorizationId, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(authorizationId)) return 0; + + var revoked = 0; + await foreach (var token in tokenManager.FindByAuthorizationIdAsync(authorizationId, ct)) + { + if (await tokenManager.TryRevokeAsync(token, ct)) + revoked++; + } + return revoked; + } + + public async Task RevokeAuthorizationByIdAsync(string authorizationId, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(authorizationId)) return false; + var authorization = await authorizationManager.FindByIdAsync(authorizationId, ct); + return authorization is not null && await authorizationManager.TryRevokeAsync(authorization, ct); + } } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs index 0e7adb61..7ba933c1 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs @@ -57,17 +57,20 @@ public sealed class RefreshTokenReuseAuditHandler private readonly IOpenIddictTokenManager _tokenManager; private readonly IOpenIddictApplicationManager _applicationManager; private readonly ISecurityAuditLog _securityAudit; + private readonly IEnumerable _observers; private readonly ILogger _logger; public RefreshTokenReuseAuditHandler( IOpenIddictTokenManager tokenManager, IOpenIddictApplicationManager applicationManager, ISecurityAuditLog securityAudit, + IEnumerable observers, ILogger logger) { _tokenManager = tokenManager; _applicationManager = applicationManager; _securityAudit = securityAudit; + _observers = observers; _logger = logger; } @@ -132,5 +135,25 @@ public async ValueTask HandleAsync(ValidateTokenContext context) Reason = $"clientId={clientId ?? "(unknown)"} authorizationId={authorizationId ?? "(unknown)"} revokedTokens={familySize}", Message = $"Refresh token reuse detected for client '{clientId ?? "(unknown)"}' — revoking {familySize} token(s) and the parent authorization", }); + + // Keep higher-level session models in sync with OpenIddict's imminent + // token-family teardown. Observer failures must never interrupt the + // stock security response that runs immediately after this handler. + foreach (var observer in _observers) + { + try + { + await observer.OnReuseDetectedAsync( + subject, clientId, authorizationId, context.CancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "refresh-token reuse observer {ObserverType} failed for authorization {AuthorizationId}", + observer.GetType().Name, + authorizationId); + } + } } } diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs index 7a5f1932..55db95ab 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs @@ -5,6 +5,7 @@ using Modgud.Infrastructure.Authorization; using Modgud.Infrastructure.OAuth; using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; using ErrorOr; using Marten; using Microsoft.Extensions.DependencyInjection; @@ -103,6 +104,7 @@ public sealed class RealmProvisioningService : IRealmProvisioningService private readonly IRealmCache _realmCache; private readonly IServiceProvider _serviceProvider; private readonly ISecurityAuditLog _securityAudit; + private readonly IReadOnlyList _jobScheduleObservers; private readonly ILogger _logger; public RealmProvisioningService( @@ -112,6 +114,7 @@ public RealmProvisioningService( IRealmCache realmCache, IServiceProvider serviceProvider, ISecurityAuditLog securityAudit, + IEnumerable jobScheduleObservers, ILogger logger) { _globalStore = globalStore; @@ -120,6 +123,7 @@ public RealmProvisioningService( _realmCache = realmCache; _serviceProvider = serviceProvider; _securityAudit = securityAudit; + _jobScheduleObservers = jobScheduleObservers.ToList(); _logger = logger; } @@ -305,6 +309,7 @@ await AppRealmSeeder.SeedAsync( ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return realm; } @@ -423,6 +428,7 @@ public async Task> UpdateRealmAsync( await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return realm; } @@ -453,6 +459,7 @@ public async Task> DeleteRealmAsync(string slug, CancellationToken await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); return true; } @@ -515,6 +522,7 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT session.Delete(realm); await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogWarning( "Hard-deleted realm {Slug}: dropped tenant database {DbName} and removed the global Realm record. " + @@ -560,6 +568,7 @@ public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken c await session.SaveChangesAsync(ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogWarning( "Rolled back partially-provisioned realm {Slug} after a post-create bootstrap failure. " + @@ -731,6 +740,8 @@ await AppRealmSeeder.SeedAsync( targetSlug); } + await ReconcileJobSchedulesAsync(ct); + _logger.LogWarning( "Control plane transferred to realm {Slug} (cleared {Count} previous holder(s))", targetSlug, otherHolders.Count); @@ -839,6 +850,7 @@ await seederScope.ServiceProvider await AppRealmSeeder.SeedAsync(_serviceProvider, slug, isControlPlane: false, _logger, ct); _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); _logger.LogInformation("Adopted existing database {DbName} as realm {Slug}", tenantDbName, slug); _securityAudit.Record(new SecurityAuditRecord { @@ -851,4 +863,24 @@ await seederScope.ServiceProvider }); return realm; } + + private async Task ReconcileJobSchedulesAsync(CancellationToken ct) + { + foreach (var observer in _jobScheduleObservers) + { + try + { + await observer.ReconcileAsync(ct); + } + catch (Exception ex) + { + // Realm mutations are already committed at every call site. + // Scheduling is in-memory and self-heals on restart, so a + // reconcile failure must not turn a successful lifecycle + // operation into a misleading HTTP/CLI failure. + _logger.LogError(ex, + "Realm lifecycle mutation committed, but Quartz schedules could not be reconciled"); + } + } + } } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs index 84108797..a3bfee61 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/IJobRegistry.cs @@ -1,10 +1,10 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// Startup-time catalogue of all known system jobs. Each registration ships -/// with a default cron — admin can override via the Jobs page in the UI, -/// stored as a Marten document and applied on the -/// next startup or via a live reschedule. +/// Startup-time catalogue of all known compiled jobs. Each registration ships +/// with an ownership scope and default cron. Realm overrides are tenant-owned; +/// system overrides live in the global store and are controlled by the current +/// Control Plane. /// public interface IJobRegistry { diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs index 36ad1d00..063335c4 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobConfig.cs @@ -6,19 +6,21 @@ namespace Modgud.Infrastructure.Scheduling; /// /// Runtime override for a job's schedule + enabled state. Marten document -/// keyed by — system jobs auto-register on startup with -/// defaults and look up a matching JobConfig to apply overrides. +/// keyed by . Realm-job documents live in the owning realm's +/// tenant database. Deployment-wide job documents live in the non-tenanted +/// global store and are merely exposed through the current Control Plane. /// -/// Phase 1 only persists overrides for system jobs. The -/// slot is reserved for future JsEval-authored jobs (Modules) — kept on the -/// same document so the storage shape doesn't change later. +/// The slot is reserved for future JsEval-authored +/// jobs (Modules) — kept on the same document so the storage shape doesn't +/// change later. /// [DocumentAlias("job_config")] public record JobConfig { /// - /// Job identifier — matches the Key a system job registered with, - /// or the unique identifier of a future script job. + /// Job identifier — matches the Key a compiled job registered with, + /// or the unique identifier of a future script job. Storage scope keeps + /// identical realm-job keys isolated. /// [Identity] public string Key { get; init; } = string.Empty; @@ -29,7 +31,7 @@ public record JobConfig public JobKind Kind { get; init; } = JobKind.System; /// - /// Cron expression. null = use the system job's default. Quartz + /// Cron expression. null = use the registered job's default. Quartz /// cron format (7 fields: sec min hour day-of-month month day-of-week year). /// public string? CronOverride { get; init; } @@ -46,7 +48,7 @@ public record JobConfig public string? ScriptSource { get; init; } /// - /// Display name (script jobs need a user-set name; system jobs default + /// Display name (script jobs need a user-set name; compiled jobs default /// to the registration's name and ignore this). /// public string? DisplayName { get; init; } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs index e9c75f40..67073e18 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRegistration.cs @@ -4,10 +4,28 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// Compile-time description of a system job. Registered via -/// AddSystemJob<TJob>(...) at startup. The registry walks all -/// registrations, applies any matching overrides -/// from Marten, and schedules them in Quartz. +/// Defines who owns a scheduled job. +/// +public enum JobScope +{ + /// + /// One independent Quartz job + trigger per realm. Configuration and run + /// history live in that realm's tenant database. + /// + Realm, + + /// + /// One deployment-wide Quartz job. It is visible and configurable only + /// from the realm that currently holds the Control-Plane role. + /// + System, +} + +/// +/// Compile-time description of a compiled job. Registered via +/// AddRealmJob<TJob>(...) or AddSystemJob<TJob>(...) +/// at startup. The registry applies the owning realm's matching +/// and schedules the appropriate Quartz instance(s). /// public sealed record JobRegistration { @@ -17,8 +35,17 @@ public sealed record JobRegistration /// Quartz cron expression (7 fields). Used when no override exists. public required string DefaultCron { get; init; } public JobKind Kind { get; init; } = JobKind.System; - /// The compiled job type (must implement ). Required for System jobs. + /// The compiled job type (must implement ). public required Type JobType { get; init; } + public required JobScope Scope { get; init; } + + /// + /// Realm jobs normally stop when their realm is deactivated. Set this only + /// for tenant-owned hygiene that must continue while a soft-deleted realm's + /// database still exists (for example expired private-key cleanup). + /// Ignored for . + /// + public bool RunWhenRealmInactive { get; init; } /// /// Optional factory returning the job's configurable inputs. The job is diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs index c73d022f..79cf2ac1 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryEntry.cs @@ -4,9 +4,9 @@ namespace Modgud.Infrastructure.Scheduling; /// -/// One execution record per job run. Written by -/// after the job's Execute returns (success or fail). Append-only — -/// admin UI shows the last N entries for each job key. +/// One execution record per job run. Realm-job history lives in the owning +/// tenant database; system-job history lives in the non-tenanted global store. +/// Written by after Execute returns. /// [DocumentAlias("job_run_history")] public record JobRunHistoryEntry diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs index af07166c..63066dc7 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunHistoryRetentionService.cs @@ -9,7 +9,20 @@ public class JobRunHistoryRetentionService( IDocumentSession session, ILogger logger) : IJobRunHistoryRetentionService { - public async Task ExecuteAsync(JobRunHistoryRetentionConfig config, CancellationToken ct = default) + public Task ExecuteAsync( + JobRunHistoryRetentionConfig config, + CancellationToken ct = default) => + ExecuteAsync(session, config, logger, ct); + + /// + /// Store-agnostic retention core. Realm jobs pass their tenant session; + /// the system retention job passes a global-store session. + /// + public static async Task ExecuteAsync( + IDocumentSession target, + JobRunHistoryRetentionConfig config, + ILogger logger, + CancellationToken ct = default) { var deletedByAge = 0; var deletedByCount = 0; @@ -20,11 +33,11 @@ public async Task ExecuteAsync(JobRunHistoryRetent // Marten maps DateTime → timestamp without time zone, so the // literal must be Kind=Unspecified to avoid Npgsql's mixed-kind error. var cutoff = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-maxAge), DateTimeKind.Unspecified); - var ids = await session.Query() + var ids = await target.Query() .Where(e => e.StartedAt < cutoff) .Select(e => e.Id) .ToListAsync(ct); - foreach (var id in ids) session.Delete(id); + foreach (var id in ids) target.Delete(id); deletedByAge = ids.Count; } @@ -33,7 +46,7 @@ public async Task ExecuteAsync(JobRunHistoryRetent // round-trip per pass is cheaper than N grouped subqueries. if (config.MaxEntriesPerJob is int maxPerJob && maxPerJob > 0) { - var all = await session.Query() + var all = await target.Query() .Select(e => new { e.Id, e.JobKey, e.StartedAt }) .ToListAsync(ct); var stale = all @@ -41,13 +54,13 @@ public async Task ExecuteAsync(JobRunHistoryRetent .SelectMany(g => g.OrderByDescending(x => x.StartedAt).Skip(maxPerJob)) .Select(x => x.Id) .ToList(); - foreach (var id in stale) session.Delete(id); + foreach (var id in stale) target.Delete(id); deletedByCount = stale.Count; } if (deletedByAge + deletedByCount > 0) { - await session.SaveChangesAsync(ct); + await target.SaveChangesAsync(ct); logger.LogInformation( "[Jobs:HistoryRetention] Deleted {ByAge} by age, {ByCount} by count", deletedByAge, deletedByCount); diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs index 8a781e54..6b342cc8 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobRunListener.cs @@ -1,6 +1,7 @@ using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Modgud.Infrastructure.Persistence.Tenancy; using Quartz; namespace Modgud.Infrastructure.Scheduling; @@ -11,11 +12,14 @@ namespace Modgud.Infrastructure.Scheduling; /// an optional per-run ResultSummary the job can publish via /// context.Result = "...";. /// -/// Resolves a fresh DI scope each run so the IDocumentSession is short-lived -/// and not entangled with whatever the job itself uses. +/// Resolves a fresh DI scope inside the owning realm carried by the Quartz +/// job detail. Realm history stays in that tenant DB; system history stays +/// in the non-tenanted global store while notifications resolve in the current +/// Control-Plane realm. /// public class JobRunListener( IServiceScopeFactory scopeFactory, + IGlobalStore globalStore, ILogger logger) : IJobListener { public string Name => nameof(JobRunListener); @@ -39,6 +43,28 @@ public async Task JobWasExecuted( CancellationToken cancellationToken = default) { var key = context.JobDetail.Key.Name; + if (!context.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.TenantSlugDataKey, out var rawTenant) + || rawTenant is not string tenantSlug + || string.IsNullOrWhiteSpace(tenantSlug)) + { + logger.LogError( + "[Jobs] Cannot persist run history for {Key}: Quartz job has no owning realm", + context.JobDetail.Key); + return; + } + + if (!context.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.JobScopeDataKey, out var rawScope) + || rawScope is not string scopeName + || !Enum.TryParse(scopeName, out var jobScope)) + { + logger.LogError( + "[Jobs] Cannot persist run history for {Key}: Quartz job has no valid ownership scope", + context.JobDetail.Key); + return; + } + var startedAt = context.Get(StartTimeKey) as DateTime? ?? context.FireTimeUtc.UtcDateTime; var finishedAt = DateTime.UtcNow; var manual = context.MergedJobDataMap.TryGetValue(ManualTriggerKey, out var m) && m is true; @@ -62,17 +88,29 @@ public async Task JobWasExecuted( TriggeredByUserId = triggeredBy, }; + using var tenant = TenantContext.Enter(tenantSlug); using var scope = scopeFactory.CreateScope(); try { - var session = scope.ServiceProvider.GetRequiredService(); - session.Store(entry); - await session.SaveChangesAsync(cancellationToken); + if (jobScope == JobScope.System) + { + await using var systemSession = globalStore.LightweightSession(); + systemSession.Store(entry); + await systemSession.SaveChangesAsync(cancellationToken); + } + else + { + var realmSession = scope.ServiceProvider.GetRequiredService(); + realmSession.Store(entry); + await realmSession.SaveChangesAsync(cancellationToken); + } } catch (Exception ex) { // Persisting history must never crash the listener — log and move on. - logger.LogWarning(ex, "[Jobs] Failed to persist run history for {Key}", key); + logger.LogWarning(ex, + "[Jobs] Failed to persist run history for {Key} in realm {Realm}", + key, tenantSlug); } // Inbox-side notify: failures → admins, manual completions → trigger user. @@ -85,7 +123,9 @@ public async Task JobWasExecuted( } catch (Exception ex) { - logger.LogWarning(ex, "[Jobs] Job-run notify failed for {Key}", key); + logger.LogWarning(ex, + "[Jobs] Job-run notify failed for {Key} in realm {Realm}", + key, tenantSlug); } } diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs index 74f04e5c..1ce959f2 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/JobsService.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging; using Quartz; using Modgud.Application.Scheduling; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; namespace Modgud.Infrastructure.Scheduling; @@ -12,105 +14,141 @@ namespace Modgud.Infrastructure.Scheduling; /// run history from Marten. The single non-trivial bit is the cron-reschedule /// path — we delete and recreate the trigger to keep semantics simple. /// -public class JobsService( +internal sealed class JobsService( IJobRegistry registry, ISchedulerFactory schedulerFactory, + RealmJobScheduler jobScheduler, + IGlobalStore globalStore, IDocumentSession session, ILogger logger) : IJobsService { public async Task> GetAllAsync(CancellationToken ct = default) { - var configs = await session.Query().ToListAsync(ct); - var configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + var realm = await GetCurrentRealmAsync(ct); + var registrations = VisibleRegistrations(realm.IsControlPlane).ToList(); + var realmKeys = registrations + .Where(r => r.Scope == JobScope.Realm) + .Select(r => r.Key) + .ToArray(); + var systemKeys = registrations + .Where(r => r.Scope == JobScope.System) + .Select(r => r.Key) + .ToArray(); - // Latest run per key, in a single query. - var registrationKeys = registry.All.Select(r => r.Key).ToList(); - var allHistory = registrationKeys.Count == 0 - ? new List() - : await session.Query() - .Where(h => h.JobKey.IsOneOf(registrationKeys.ToArray())) - .OrderByDescending(h => h.StartedAt) - .ToListAsync(ct); + var (configs, allHistory) = await LoadStateAsync(session, realmKeys, ct); + if (systemKeys.Length > 0) + { + await using var systemSession = globalStore.QuerySession(); + var (systemConfigs, systemHistory) = await LoadStateAsync( + systemSession, systemKeys, ct); + configs.AddRange(systemConfigs); + allHistory.AddRange(systemHistory); + } + + var configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); var latestByKey = allHistory .GroupBy(h => h.JobKey) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + .ToDictionary( + g => g.Key, + g => g.OrderByDescending(h => h.StartedAt).First(), + StringComparer.OrdinalIgnoreCase); var scheduler = await schedulerFactory.GetScheduler(ct); - var result = new List(registry.All.Count); - foreach (var reg in registry.All) + var result = new List(registrations.Count); + foreach (var reg in registrations) { configByKey.TryGetValue(reg.Key, out var cfg); latestByKey.TryGetValue(reg.Key, out var lastRun); - result.Add(await BuildOverviewAsync(reg, cfg, lastRun, scheduler, ct)); + result.Add(await BuildOverviewAsync(reg, realm.Slug, cfg, lastRun, scheduler, ct)); } return result; } public async Task GetAsync(string key, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key); + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)); if (reg is null) return null; - var cfg = await session.LoadAsync(key, ct); - var lastRun = await session.Query() - .Where(h => h.JobKey == key) - .OrderByDescending(h => h.StartedAt) - .FirstOrDefaultAsync(ct); + JobConfig? cfg; + JobRunHistoryEntry? lastRun; + if (reg.Scope == JobScope.System) + { + await using var systemSession = globalStore.QuerySession(); + cfg = await systemSession.LoadAsync(reg.Key, ct); + lastRun = await GetLastRunAsync(systemSession, reg.Key, ct); + } + else + { + cfg = await session.LoadAsync(reg.Key, ct); + lastRun = await GetLastRunAsync(session, reg.Key, ct); + } var scheduler = await schedulerFactory.GetScheduler(ct); - return await BuildOverviewAsync(reg, cfg, lastRun, scheduler, ct); + return await BuildOverviewAsync(reg, realm.Slug, cfg, lastRun, scheduler, ct); } public async Task> GetHistoryAsync(string key, int take = 50, CancellationToken ct = default) { + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Unknown job key '{key}'"); + if (take < 1) take = 1; if (take > 500) take = 500; - var entries = await session.Query() - .Where(h => h.JobKey == key) - .OrderByDescending(h => h.StartedAt) - .Take(take) - .ToListAsync(ct); + List entries; + if (reg.Scope == JobScope.System) + { + await using var systemSession = globalStore.QuerySession(); + entries = await GetHistoryAsync(systemSession, reg.Key, take, ct); + } + else + { + entries = await GetHistoryAsync(session, reg.Key, take, ct); + } + return entries.Select(ToDto).ToList(); } public async Task UpdateAsync(string key, JobUpdateDto update, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key) + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Unknown job key '{key}'"); - var existing = await session.LoadAsync(key, ct); - var nextParams = existing?.Parameters; - if (update.Parameters is not null) + JobConfig cfg; + if (reg.Scope == JobScope.System) { - // Drop unknown keys so a stale UI can't smuggle garbage into the doc. - var schemaKeys = reg.GetParameterSchema?.Invoke().Select(f => f.Key).ToHashSet(StringComparer.Ordinal) - ?? new HashSet(StringComparer.Ordinal); - nextParams = update.Parameters - .Where(kv => schemaKeys.Contains(kv.Key)) - .ToDictionary(kv => kv.Key, kv => kv.Value); + await using var systemSession = globalStore.LightweightSession(); + var existing = await systemSession.LoadAsync(reg.Key, ct); + cfg = BuildConfig(reg, update, existing); + systemSession.Store(cfg); + await systemSession.SaveChangesAsync(ct); } - - var cfg = (existing ?? new JobConfig { Key = key, Kind = reg.Kind, CreatedAt = DateTime.UtcNow }) with + else { - CronOverride = update.CronOverride, - Enabled = update.Enabled ?? existing?.Enabled ?? true, - Parameters = nextParams, - UpdatedAt = DateTime.UtcNow, - }; - session.Store(cfg); - await session.SaveChangesAsync(ct); + var existing = await session.LoadAsync(reg.Key, ct); + cfg = BuildConfig(reg, update, existing); + session.Store(cfg); + await session.SaveChangesAsync(ct); + } - await RescheduleAsync(reg, cfg, ct); + await jobScheduler.ApplyAsync(reg, realm.Slug, cfg, ct); } public async Task TriggerNowAsync(string key, Guid? triggeredByUserId = null, CancellationToken ct = default) { - var reg = registry.All.FirstOrDefault(r => r.Key == key) + var realm = await GetCurrentRealmAsync(ct); + var reg = VisibleRegistrations(realm.IsControlPlane) + .FirstOrDefault(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Unknown job key '{key}'"); var scheduler = await schedulerFactory.GetScheduler(ct); - var jobKey = new JobKey(reg.Key); + var jobKey = RealmJobScheduler.GetJobKey(reg, realm.Slug); if (!await scheduler.CheckExists(jobKey, ct)) throw new InvalidOperationException($"Job '{reg.Key}' is not registered with the scheduler"); @@ -122,59 +160,111 @@ public async Task TriggerNowAsync(string key, Guid? triggeredByUserId = null, Ca if (triggeredByUserId is Guid uid && uid != Guid.Empty) data[JobRunListener.TriggeredByUserIdKey] = uid; await scheduler.TriggerJob(jobKey, data, ct); - logger.LogInformation("[Jobs] Manual trigger for {Key} by user {UserId}", - reg.Key, triggeredByUserId?.ToString() ?? "(unknown)"); + logger.LogInformation( + "[Jobs] Manual trigger for {Key} in realm {Realm} by user {UserId}", + reg.Key, realm.Slug, triggeredByUserId?.ToString() ?? "(unknown)"); } // ── helpers ───────────────────────────────────────────────────── - /// - /// Apply the (possibly new) to Quartz: re-schedule - /// with the effective cron, or unschedule if disabled. - /// - public async Task RescheduleAsync(JobRegistration reg, JobConfig? cfg, CancellationToken ct = default) + private IEnumerable VisibleRegistrations(bool isControlPlane) { - var scheduler = await schedulerFactory.GetScheduler(ct); - var jobKey = new JobKey(reg.Key); - var triggerKey = new TriggerKey($"{reg.Key}-trigger"); + return registry.All.Where(r => + r.Scope == JobScope.Realm + || (r.Scope == JobScope.System && isControlPlane)); + } - // Always make sure the job exists. - if (!await scheduler.CheckExists(jobKey, ct)) - { - var jobDetail = JobBuilder.Create(reg.JobType) - .WithIdentity(jobKey) - .WithDescription(reg.Description) - .StoreDurably() - .Build(); - await scheduler.AddJob(jobDetail, replace: false, ct); - } + private async Task GetCurrentRealmAsync(CancellationToken ct) + { + var slug = TenantContext.Current; + await using var globalSession = globalStore.QuerySession(); + return await globalSession.Query() + .FirstOrDefaultAsync(r => r.Slug == slug, ct) + ?? throw new InvalidOperationException($"Unknown current realm '{slug}'"); + } - await scheduler.UnscheduleJob(triggerKey, ct); + private static async Task<(List Configs, List History)> LoadStateAsync( + IQuerySession source, + string[] keys, + CancellationToken ct) + { + if (keys.Length == 0) + return ([], []); + + var configs = await source.Query() + .Where(c => c.Key.IsOneOf(keys)) + .ToListAsync(ct); + var history = await source.Query() + .Where(h => h.JobKey.IsOneOf(keys)) + .ToListAsync(ct); + return (configs.ToList(), history.ToList()); + } + + private static Task GetLastRunAsync( + IQuerySession source, + string key, + CancellationToken ct) => + source.Query() + .Where(h => h.JobKey == key) + .OrderByDescending(h => h.StartedAt) + .FirstOrDefaultAsync(ct); + + private static async Task> GetHistoryAsync( + IQuerySession source, + string key, + int take, + CancellationToken ct) + { + var entries = await source.Query() + .Where(h => h.JobKey == key) + .OrderByDescending(h => h.StartedAt) + .Take(take) + .ToListAsync(ct); + return entries.ToList(); + } - if (cfg is not null && !cfg.Enabled) + private static JobConfig BuildConfig( + JobRegistration registration, + JobUpdateDto update, + JobConfig? existing) + { + var nextParams = existing?.Parameters; + if (update.Parameters is not null) { - logger.LogInformation("[Jobs] {Key} is disabled — no trigger scheduled", reg.Key); - return; + // Drop unknown keys so a stale UI can't smuggle garbage into the doc. + var schemaKeys = registration.GetParameterSchema?.Invoke() + .Select(f => f.Key) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); + nextParams = update.Parameters + .Where(kv => schemaKeys.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); } - var cron = cfg?.CronOverride ?? reg.DefaultCron; - var trigger = TriggerBuilder.Create() - .WithIdentity(triggerKey) - .ForJob(jobKey) - .WithCronSchedule(cron) - .Build(); - await scheduler.ScheduleJob(trigger, ct); - logger.LogInformation("[Jobs] Scheduled {Key} with cron '{Cron}'", reg.Key, cron); + return (existing ?? new JobConfig + { + Key = registration.Key, + Kind = registration.Kind, + CreatedAt = DateTime.UtcNow, + }) with + { + CronOverride = update.CronOverride, + Enabled = update.Enabled ?? existing?.Enabled ?? true, + Parameters = nextParams, + UpdatedAt = DateTime.UtcNow, + }; } private static async Task BuildOverviewAsync( JobRegistration reg, + string realmSlug, JobConfig? cfg, JobRunHistoryEntry? lastRun, IScheduler scheduler, CancellationToken ct) { - var triggers = await scheduler.GetTriggersOfJob(new JobKey(reg.Key), ct); + var triggers = await scheduler.GetTriggersOfJob( + RealmJobScheduler.GetJobKey(reg, realmSlug), ct); DateTime? next = triggers .Select(t => t.GetNextFireTimeUtc()?.UtcDateTime) .Where(d => d.HasValue) @@ -190,6 +280,7 @@ private static async Task BuildOverviewAsync( Name = cfg?.DisplayName ?? reg.Name, Description = cfg?.Description ?? reg.Description, Kind = reg.Kind.ToString(), + Scope = reg.Scope.ToString(), EffectiveCron = cfg?.CronOverride ?? reg.DefaultCron, DefaultCron = reg.DefaultCron, HasOverride = !string.IsNullOrWhiteSpace(cfg?.CronOverride), diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs new file mode 100644 index 00000000..cea213ca --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/RealmJobScheduler.cs @@ -0,0 +1,275 @@ +using Marten; +using Microsoft.Extensions.Logging; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Persistence.Tenancy; +using Quartz; +using Quartz.Impl.Matchers; + +namespace Modgud.Infrastructure.Scheduling; + +/// +/// Optional realm-lifecycle hook. Scheduling registers an implementation; +/// hosts that use realm provisioning without Quartz simply have no observers. +/// +public interface IRealmJobScheduleObserver +{ + Task ReconcileAsync(CancellationToken ct = default); +} + +/// +/// Owns the mapping from Modgud's realm/system job model to Quartz identities. +/// Realm jobs use one Quartz group per realm. System jobs share one reserved +/// group and carry the current Control-Plane realm as their tenant context. +/// +internal sealed class RealmJobScheduler( + ISchedulerFactory schedulerFactory, + IJobRegistry registry, + IGlobalStore globalStore, + IDocumentStore tenantStore, + ILogger logger) : IRealmJobScheduleObserver +{ + internal const string TenantSlugDataKey = "__modgudTenantSlug"; + internal const string JobScopeDataKey = "__modgudJobScope"; + private const string RealmGroupPrefix = "realm:"; + private const string SystemGroup = "system"; + + private readonly SemaphoreSlim _mutationLock = new(1, 1); + + public async Task ReconcileAsync(CancellationToken ct = default) + { + await _mutationLock.WaitAsync(ct); + try + { + await using var globalSession = globalStore.QuerySession(); + var realms = await globalSession.Query() + .OrderBy(r => r.CreatedAt) + .ToListAsync(ct); + + var scheduler = await schedulerFactory.GetScheduler(ct); + await RemoveDeletedRealmGroupsAsync(scheduler, realms, ct); + + foreach (var realm in realms) + { + await ReconcileRealmJobsAsync(scheduler, realm, ct); + } + + await ReconcileSystemJobsAsync(scheduler, realms, ct); + } + finally + { + _mutationLock.Release(); + } + } + + public async Task ApplyAsync( + JobRegistration registration, + string realmSlug, + JobConfig? config, + CancellationToken ct = default) + { + await _mutationLock.WaitAsync(ct); + try + { + var scheduler = await schedulerFactory.GetScheduler(ct); + await ApplyCoreAsync(scheduler, registration, realmSlug, config, ct); + } + finally + { + _mutationLock.Release(); + } + } + + internal static JobKey GetJobKey(JobRegistration registration, string realmSlug) => + new(registration.Key, GetGroup(registration, realmSlug)); + + private static TriggerKey GetTriggerKey(JobRegistration registration, string realmSlug) => + new($"{registration.Key}-trigger", GetGroup(registration, realmSlug)); + + private static string GetGroup(JobRegistration registration, string realmSlug) => + registration.Scope == JobScope.System + ? SystemGroup + : $"{RealmGroupPrefix}{realmSlug}"; + + private async Task ReconcileRealmJobsAsync( + IScheduler scheduler, + Realm realm, + CancellationToken ct) + { + var registrations = registry.All + .Where(r => r.Scope == JobScope.Realm + && (realm.IsActive || r.RunWhenRealmInactive)) + .ToList(); + + var expectedKeys = registrations + .Select(r => GetJobKey(r, realm.Slug)) + .ToHashSet(); + + var group = $"{RealmGroupPrefix}{realm.Slug}"; + var existingKeys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(group), ct); + var obsoleteKeys = existingKeys.Where(k => !expectedKeys.Contains(k)).ToList(); + if (obsoleteKeys.Count > 0) + await scheduler.DeleteJobs(obsoleteKeys, ct); + + if (registrations.Count == 0) + return; + + Dictionary configByKey; + try + { + await using var session = tenantStore.QuerySession(realm.Slug); + var configs = await session.Query().ToListAsync(ct); + configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Could not load job configuration for realm {Realm}; its schedules were not reconciled", + realm.Slug); + return; + } + + foreach (var registration in registrations) + { + configByKey.TryGetValue(registration.Key, out var config); + try + { + await ApplyCoreAsync(scheduler, registration, realm.Slug, config, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Failed to schedule {Key} for realm {Realm}", + registration.Key, realm.Slug); + } + } + } + + private async Task ReconcileSystemJobsAsync( + IScheduler scheduler, + IReadOnlyList realms, + CancellationToken ct) + { + var registrations = registry.All.Where(r => r.Scope == JobScope.System).ToList(); + var controlPlanes = realms.Where(r => r.IsControlPlane && r.IsActive).ToList(); + + if (controlPlanes.Count != 1) + { + var existing = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(SystemGroup), ct); + if (existing.Count > 0) + await scheduler.DeleteJobs(existing.ToList(), ct); + + logger.LogError( + "[Jobs] Expected exactly one active Control-Plane realm but found {Count}; system jobs are unscheduled", + controlPlanes.Count); + return; + } + + var controlPlane = controlPlanes[0]; + Dictionary configByKey; + try + { + // System-job configuration belongs to the deployment, not to any + // tenant database. The current Control Plane controls it, but a + // transfer must not reset or resurrect another realm's schedule. + await using var session = globalStore.QuerySession(); + var configs = await session.Query().ToListAsync(ct); + configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Could not load system-job configuration from Control-Plane realm {Realm}", + controlPlane.Slug); + return; + } + + var expectedKeys = registrations + .Select(r => GetJobKey(r, controlPlane.Slug)) + .ToHashSet(); + var existingKeys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(SystemGroup), ct); + var obsoleteKeys = existingKeys.Where(k => !expectedKeys.Contains(k)).ToList(); + if (obsoleteKeys.Count > 0) + await scheduler.DeleteJobs(obsoleteKeys, ct); + + foreach (var registration in registrations) + { + configByKey.TryGetValue(registration.Key, out var config); + try + { + await ApplyCoreAsync(scheduler, registration, controlPlane.Slug, config, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, + "[Jobs] Failed to schedule system job {Key} for Control-Plane realm {Realm}", + registration.Key, controlPlane.Slug); + } + } + } + + private async Task ApplyCoreAsync( + IScheduler scheduler, + JobRegistration registration, + string realmSlug, + JobConfig? config, + CancellationToken ct) + { + var jobKey = GetJobKey(registration, realmSlug); + var triggerKey = GetTriggerKey(registration, realmSlug); + var jobDetail = JobBuilder.Create(registration.JobType) + .WithIdentity(jobKey) + .WithDescription(registration.Description) + .UsingJobData(TenantSlugDataKey, realmSlug) + .UsingJobData(JobScopeDataKey, registration.Scope.ToString()) + .StoreDurably() + .Build(); + + await scheduler.AddJob(jobDetail, replace: true, ct); + await scheduler.UnscheduleJob(triggerKey, ct); + + if (config is not null && !config.Enabled) + { + logger.LogInformation( + "[Jobs] {Key} is manual-only for realm {Realm} — registered without a trigger", + registration.Key, realmSlug); + return; + } + + var cron = config?.CronOverride ?? registration.DefaultCron; + var trigger = TriggerBuilder.Create() + .WithIdentity(triggerKey) + .ForJob(jobKey) + .WithCronSchedule(cron) + .Build(); + await scheduler.ScheduleJob(trigger, ct); + + logger.LogInformation( + "[Jobs] Scheduled {Scope} job {Key} for realm {Realm} with cron '{Cron}'", + registration.Scope, registration.Key, realmSlug, cron); + } + + private async Task RemoveDeletedRealmGroupsAsync( + IScheduler scheduler, + IReadOnlyCollection realms, + CancellationToken ct) + { + var knownGroups = realms + .Select(r => $"{RealmGroupPrefix}{r.Slug}") + .ToHashSet(StringComparer.Ordinal); + var groups = await scheduler.GetJobGroupNames(ct); + + foreach (var group in groups.Where(g => + g.StartsWith(RealmGroupPrefix, StringComparison.Ordinal) + && !knownGroups.Contains(g))) + { + var keys = await scheduler.GetJobKeys( + GroupMatcher.GroupEquals(group), ct); + if (keys.Count > 0) + await scheduler.DeleteJobs(keys.ToList(), ct); + } + } + +} diff --git a/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs index 97dfcb67..0f26f8d3 100644 --- a/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/Scheduling/SchedulingDependencyInjection.cs @@ -1,10 +1,9 @@ -using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Quartz; using Quartz.Spi; using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Persistence.Tenancy; namespace Modgud.Infrastructure.Scheduling; @@ -12,11 +11,9 @@ public static class SchedulingDependencyInjection { /// /// Wire Quartz.NET with an in-memory job store, register the - /// facade and the run-history listener. The - /// host that calls this is responsible for calling - /// AddSystemJob<TJob>(...) for each compiled job to register - /// it with ; everything is scheduled inside - /// a hosted bootstrap step. + /// facade and the run-history listener. Hosts + /// register every compiled job explicitly as realm-owned or system-owned; + /// a hosted bootstrap materialises the corresponding Quartz instances. /// public static IServiceCollection AddScheduling(this IServiceCollection services) { @@ -27,6 +24,9 @@ public static IServiceCollection AddScheduling(this IServiceCollection services) // this binding (Modgud.Api does so in Program.cs). services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService()); services.AddQuartz(q => { @@ -46,17 +46,39 @@ public static IServiceCollection AddScheduling(this IServiceCollection services) // jobs can pull dependencies (e.g. IDocumentStore) from scope. services.AddSingleton(); - // Boot step: after the host has started but before HTTP requests arrive, - // walk the JobRegistry, apply Marten JobConfig overrides, and schedule - // each job in Quartz. Also attach the JobRunListener at this point so - // it sees every subsequent execution. + // Boot step: after the host has started, reconcile every realm's + // independent schedule and the single Control-Plane system schedule. services.AddHostedService(); return services; } /// - /// Register a compiled job type. Call once per job at startup. + /// Register a compiled job that gets one independent Quartz job and trigger + /// per realm. + /// + public static IServiceCollection AddRealmJob( + this IServiceCollection services, + string key, + string name, + string defaultCron, + string? description = null, + Func>? getParameterSchema = null, + bool runWhenRealmInactive = false) + where TJob : class, IJob + => AddJob( + services, + key, + name, + defaultCron, + JobScope.Realm, + description, + getParameterSchema, + runWhenRealmInactive); + + /// + /// Register one deployment-wide compiled job. It is scheduled once and is + /// visible/configurable only in the current Control-Plane realm. /// public static IServiceCollection AddSystemJob( this IServiceCollection services, @@ -66,8 +88,28 @@ public static IServiceCollection AddSystemJob( string? description = null, Func>? getParameterSchema = null) where TJob : class, IJob + => AddJob( + services, + key, + name, + defaultCron, + JobScope.System, + description, + getParameterSchema, + runWhenRealmInactive: false); + + private static IServiceCollection AddJob( + IServiceCollection services, + string key, + string name, + string defaultCron, + JobScope scope, + string? description, + Func>? getParameterSchema, + bool runWhenRealmInactive) + where TJob : class, IJob { - services.AddTransient(); // resolved by MicrosoftDependencyInjectionJobFactory + services.AddTransient(); services.AddSingleton(new JobRegistration { Key = key, @@ -76,6 +118,8 @@ public static IServiceCollection AddSystemJob( DefaultCron = defaultCron, JobType = typeof(TJob), Kind = JobKind.System, + Scope = scope, + RunWhenRealmInactive = runWhenRealmInactive, GetParameterSchema = getParameterSchema, }); return services; @@ -84,97 +128,58 @@ public static IServiceCollection AddSystemJob( /// /// Quartz job factory backed by Microsoft.Extensions.DependencyInjection. -/// Creates a scope per job execution so scoped services (IDocumentSession, -/// IJobRunHistoryRetentionService) work correctly. +/// Resolves the actual job only after entering the tenant carried by the +/// Quartz job detail. This guarantees constructor-injected scoped services +/// bind to the owning realm, even though there is no HTTP request. /// internal sealed class MicrosoftDependencyInjectionJobFactory(IServiceProvider rootProvider) : IJobFactory { public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { - var scope = rootProvider.CreateScope(); - var job = (IJob)scope.ServiceProvider.GetRequiredService(bundle.JobDetail.JobType); - // Attach the scope so we can dispose it when the job returns. - return new ScopedJobWrapper(job, scope); - } + if (!bundle.JobDetail.JobDataMap.TryGetValue( + RealmJobScheduler.TenantSlugDataKey, out var rawTenant) + || rawTenant is not string tenantSlug + || string.IsNullOrWhiteSpace(tenantSlug)) + { + throw new InvalidOperationException( + $"Scheduled job '{bundle.JobDetail.Key}' has no owning realm."); + } - public void ReturnJob(IJob job) - { - if (job is ScopedJobWrapper wrapper) wrapper.Dispose(); + return new TenantScopedJob(rootProvider, bundle.JobDetail.JobType, tenantSlug); } - private sealed class ScopedJobWrapper(IJob inner, IServiceScope scope) : IJob, IDisposable + public void ReturnJob(IJob job) { } + + private sealed class TenantScopedJob( + IServiceProvider provider, + Type jobType, + string tenantSlug) : IJob { - public Task Execute(IJobExecutionContext context) => inner.Execute(context); - public void Dispose() => scope.Dispose(); + public async Task Execute(IJobExecutionContext context) + { + using var tenant = TenantContext.Enter(tenantSlug); + using var scope = provider.CreateScope(); + var inner = (IJob)scope.ServiceProvider.GetRequiredService(jobType); + await inner.Execute(context); + } } } /// -/// Reads + at startup, -/// schedules each enabled job, and attaches the run-history listener to the -/// scheduler. Idempotent — also handles re-registration on hot-reload of the -/// host. +/// Reconciles all realm/system job instances at startup and attaches the +/// run-history listener. /// internal sealed class SchedulingBootstrap( ISchedulerFactory schedulerFactory, - IJobRegistry registry, - IServiceScopeFactory scopeFactory, - JobRunListener listener, - ILogger logger) : IHostedService + RealmJobScheduler jobScheduler, + JobRunListener listener) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { - // Pull config overrides up-front so we only open one session. - Dictionary configByKey; - using (var scope = scopeFactory.CreateScope()) - { - var session = scope.ServiceProvider.GetRequiredService(); - var configs = await session.Query().ToListAsync(cancellationToken); - configByKey = configs.ToDictionary(c => c.Key, StringComparer.OrdinalIgnoreCase); - } - var scheduler = await schedulerFactory.GetScheduler(cancellationToken); scheduler.ListenerManager.AddJobListener(listener); - - foreach (var reg in registry.All) - { - configByKey.TryGetValue(reg.Key, out var cfg); - try - { - await ApplyAsync(scheduler, reg, cfg, cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "[Jobs] Failed to schedule {Key}", reg.Key); - } - } + await jobScheduler.ReconcileAsync(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private async Task ApplyAsync(IScheduler scheduler, JobRegistration reg, JobConfig? cfg, CancellationToken ct) - { - var jobKey = new JobKey(reg.Key); - var jobDetail = JobBuilder.Create(reg.JobType) - .WithIdentity(jobKey) - .WithDescription(reg.Description) - .StoreDurably() - .Build(); - await scheduler.AddJob(jobDetail, replace: true, ct); - - if (cfg is not null && !cfg.Enabled) - { - logger.LogInformation("[Jobs] {Key} is disabled — registered but unscheduled", reg.Key); - return; - } - - var cron = cfg?.CronOverride ?? reg.DefaultCron; - var trigger = TriggerBuilder.Create() - .WithIdentity($"{reg.Key}-trigger") - .ForJob(jobKey) - .WithCronSchedule(cron) - .Build(); - await scheduler.ScheduleJob(trigger, ct); - logger.LogInformation("[Jobs] Scheduled {Key} with cron '{Cron}'", reg.Key, cron); - } } diff --git a/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs b/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs index e308d6ff..2176284f 100644 --- a/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Application/OAuthAdminMappingTests.cs @@ -1100,6 +1100,48 @@ public void Result_round_trips_through_DictEquals_against_unchanged_input() Assert.True(OAuthAdminMapping.DictEquals(current, merged)); } + + [Fact] + public void Client_session_lifetimes_can_be_set_and_cleared_independently() + { + var current = new Dictionary + { + [OAuthApplicationSettingKeys.ClientSessionIdleLifetime] = "2592000", + [OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime] = "31536000", + }; + + var merged = OAuthAdminMapping.MergeClientSettings(current, new UpdateOAuthClientDto + { + ClearClientSessionIdleLifetime = true, + ClientSessionAbsoluteLifetime = 315360000, + }); + + Assert.False(merged.ContainsKey(OAuthApplicationSettingKeys.ClientSessionIdleLifetime)); + Assert.Equal("315360000", merged[OAuthApplicationSettingKeys.ClientSessionAbsoluteLifetime]); + Assert.Equal("2592000", current[OAuthApplicationSettingKeys.ClientSessionIdleLifetime]); + } + } + + public class ValidateClientSessionLifetimes + { + [Fact] + public void Accepts_ten_year_absolute_lifetime() + { + Assert.Null(OAuthAdminMapping.ValidateClientSessionLifetimes( + 30 * 24 * 60 * 60, + 3650 * 24 * 60 * 60)); + } + + [Fact] + public void Rejects_absolute_lifetime_shorter_than_idle_lifetime() + { + var error = OAuthAdminMapping.ValidateClientSessionLifetimes( + 60 * 24 * 60 * 60, + 30 * 24 * 60 * 60); + + Assert.NotNull(error); + Assert.Equal("OAuthClient.InvalidClientSessionAbsoluteLifetime", error.Value.Code); + } } // ─────────────── Native token-lifetime wiring (issue #115) ───────────────── diff --git a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs index f38e8931..5a7cae39 100644 --- a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs @@ -23,6 +23,11 @@ public class EffectiveSettingsTests AccessTokenLifetime = TimeSpan.FromMinutes(15), RefreshTokenLifetime = TimeSpan.FromDays(14), }, + ClientSessions = new ClientSessionPolicy + { + IdleLifetime = TimeSpan.FromDays(30), + AbsoluteLifetime = TimeSpan.FromDays(365), + }, Branding = new BrandingSettings { ProductName = "RealmProduct", PrimaryColor = "#111111" }, RegistrationFields = new RegistrationFieldsSettings { @@ -48,6 +53,7 @@ public void Returns_every_realm_section_unchanged() Assert.Equal(realm.Dcr, eff.Dcr); Assert.Equal(realm.Cimd, eff.Cimd); Assert.Equal(realm.NativeGrants, eff.NativeGrants); + Assert.Equal(realm.ClientSessions, eff.ClientSessions); Assert.Equal(realm.Branding, eff.Branding); Assert.Equal(realm.RegistrationFields, eff.RegistrationFields); Assert.Equal(realm.Deletion, eff.Deletion); @@ -453,4 +459,38 @@ public void App_migration_drops_legacy_authored_schema_and_inherits() Assert.Equal("realm-login", EffectiveSettings.Merge(realm, app).Pages!["login"]); } } + + public class ClientSessionMerge + { + [Fact] + public void App_values_override_individual_realm_fields() + { + var realm = Realm(); + var app = new ApplicationSettings + { + ClientSessions = new ApplicationClientSessionOverrides + { + AbsoluteLifetime = TimeSpan.FromDays(3650), + }, + }; + + var effective = EffectiveSettings.Merge(realm, app); + + Assert.Equal(TimeSpan.FromDays(30), effective.ClientSessions!.IdleLifetime); + Assert.Equal(TimeSpan.FromDays(3650), effective.ClientSessions.AbsoluteLifetime); + } + + [Fact] + public void Empty_app_override_uses_domain_defaults_when_realm_is_unconfigured() + { + var effective = EffectiveSettings.Merge( + new RealmSettingsDoc(), + new ApplicationSettings + { + ClientSessions = new ApplicationClientSessionOverrides(), + }); + + Assert.Equal(ClientSessionPolicy.Defaults, effective.ClientSessions); + } + } } diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs new file mode 100644 index 00000000..474635b0 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/ClientSessionTests.cs @@ -0,0 +1,31 @@ +using Modgud.Authentication.Domain; + +namespace Modgud.Tests.Unit.Authentication.Domain; + +public class ClientSessionTests +{ + [Fact] + public void Touch_slides_idle_expiry_but_caps_it_at_absolute_expiry() + { + var created = DateTimeOffset.UtcNow; + var session = new ClientSession + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + ClientId = "amzettel-ios", + OAuthApplicationId = Guid.NewGuid().ToString(), + AuthorizationId = Guid.NewGuid().ToString(), + CreatedAt = created, + LastActiveAt = created, + ExpiresAt = created.AddDays(30), + AbsoluteExpiresAt = created.AddDays(3650), + }; + + session.Touch(created.AddDays(3640), TimeSpan.FromDays(30)); + + Assert.Equal(created.AddDays(3640), session.LastActiveAt); + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + Assert.True(session.IsActive(created.AddDays(3649))); + Assert.False(session.IsActive(session.AbsoluteExpiresAt)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs index 2887418f..86a2479a 100644 --- a/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Authentication/Domain/UserSessionTests.cs @@ -2,129 +2,85 @@ namespace Modgud.Tests.Unit.Authentication.Domain; -/// -/// Pins the factory + Touch behaviour. This is a -/// pure POCO so the tests are minimal — there is just enough logic to be worth -/// freezing (in particular: ExpiresAt = CreatedAt + sessionDuration). -/// public class UserSessionTests { - public class Create + private static UserSession CreateSession( + TimeSpan? idleLifetime = null, + TimeSpan? absoluteLifetime = null) => + UserSession.Create( + Guid.NewGuid(), + "10.0.0.1", + "Mozilla/5.0", + "Chrome", + "120.0", + "Windows", + "10", + "Desktop", + idleLifetime ?? TimeSpan.FromHours(1), + absoluteLifetime ?? TimeSpan.FromHours(8)); + + [Fact] + public void Create_sets_device_data_and_a_stable_id() { - [Fact] - public void Sets_all_provided_fields() - { - var userId = Guid.NewGuid(); - - var s = UserSession.Create( - userId: userId, - sessionId: "sess-1", - ipAddress: "10.0.0.1", - userAgent: "Mozilla/5.0", - browser: "Chrome", - browserVersion: "120.0", - operatingSystem: "Windows", - osVersion: "10", - deviceType: "Desktop", - sessionDuration: TimeSpan.FromHours(8)); - - Assert.Equal(userId, s.UserId); - Assert.Equal("sess-1", s.SessionId); - Assert.Equal("10.0.0.1", s.IpAddress); - Assert.Equal("Mozilla/5.0", s.UserAgent); - Assert.Equal("Chrome", s.Browser); - Assert.Equal("120.0", s.BrowserVersion); - Assert.Equal("Windows", s.OperatingSystem); - Assert.Equal("10", s.OsVersion); - Assert.Equal("Desktop", s.DeviceType); - } - - [Fact] - public void Generates_non_empty_id() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - - Assert.NotEqual(Guid.Empty, s.Id); - } - - [Fact] - public void Sets_created_and_last_active_to_the_same_moment() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - - Assert.Equal(s.CreatedAt, s.LastActiveAt); - } - - [Fact] - public void Sets_expires_at_to_created_plus_session_duration() - { - var duration = TimeSpan.FromMinutes(45); - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, duration); - - Assert.Equal(s.CreatedAt + duration, s.ExpiresAt); - } - - [Fact] - public void Created_at_is_close_to_now_in_utc() - { - var before = DateTimeOffset.UtcNow; - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var after = DateTimeOffset.UtcNow; - - Assert.InRange(s.CreatedAt, before, after); - } + var session = CreateSession(); + + Assert.NotEqual(Guid.Empty, session.Id); + Assert.Equal("10.0.0.1", session.IpAddress); + Assert.Equal("Mozilla/5.0", session.UserAgent); + Assert.Equal("Chrome", session.Browser); + Assert.Equal("120.0", session.BrowserVersion); + Assert.Equal("Windows", session.OperatingSystem); + Assert.Equal("10", session.OsVersion); + Assert.Equal("Desktop", session.DeviceType); + Assert.Equal(session.CreatedAt, session.LastActiveAt); + } - [Fact] - public void Allows_all_optional_fields_null() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); + [Fact] + public void Create_caps_idle_expiry_at_absolute_expiry() + { + var session = CreateSession( + idleLifetime: TimeSpan.FromDays(10), + absoluteLifetime: TimeSpan.FromDays(2)); - Assert.Null(s.SessionId); - Assert.Null(s.IpAddress); - Assert.Null(s.UserAgent); - Assert.Null(s.Browser); - Assert.Null(s.BrowserVersion); - Assert.Null(s.OperatingSystem); - Assert.Null(s.OsVersion); - Assert.Null(s.DeviceType); - } + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + Assert.Equal(session.CreatedAt.AddDays(2), session.AbsoluteExpiresAt); } - public class Touch + [Fact] + public void Touch_slides_idle_expiry_without_moving_absolute_expiry() { - [Fact] - public void Updates_last_active_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - // Force a measurable gap so even on a fast clock the assertion is reliable. - var initialLastActive = s.LastActiveAt; - Thread.Sleep(2); - - s.Touch(); + var session = CreateSession( + idleLifetime: TimeSpan.FromHours(1), + absoluteLifetime: TimeSpan.FromHours(8)); + var absoluteExpiry = session.AbsoluteExpiresAt; + var now = session.CreatedAt.AddMinutes(30); - Assert.True(s.LastActiveAt >= initialLastActive); - } + session.Touch(now, TimeSpan.FromHours(1)); - [Fact] - public void Does_not_change_created_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var originalCreatedAt = s.CreatedAt; + Assert.Equal(now, session.LastActiveAt); + Assert.Equal(now.AddHours(1), session.ExpiresAt); + Assert.Equal(absoluteExpiry, session.AbsoluteExpiresAt); + } - s.Touch(); + [Fact] + public void Touch_never_extends_past_absolute_expiry() + { + var session = CreateSession( + idleLifetime: TimeSpan.FromHours(1), + absoluteLifetime: TimeSpan.FromHours(2)); - Assert.Equal(originalCreatedAt, s.CreatedAt); - } + session.Touch(session.CreatedAt.AddMinutes(90), TimeSpan.FromHours(1)); - [Fact] - public void Does_not_change_expires_at() - { - var s = UserSession.Create(Guid.NewGuid(), null, null, null, null, null, null, null, null, TimeSpan.FromHours(1)); - var originalExpiry = s.ExpiresAt; + Assert.Equal(session.AbsoluteExpiresAt, session.ExpiresAt); + } - s.Touch(); + [Fact] + public void IsActive_requires_both_idle_and_absolute_windows() + { + var session = CreateSession(); - Assert.Equal(originalExpiry, s.ExpiresAt); - } + Assert.True(session.IsActive(session.CreatedAt.AddMinutes(30))); + Assert.False(session.IsActive(session.ExpiresAt)); + Assert.False(session.IsActive(session.AbsoluteExpiresAt)); } } diff --git a/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs b/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs deleted file mode 100644 index 54348ab4..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Authentication/Sessions/SessionTrackerTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Net; -using Modgud.Authentication.Domain; -using Modgud.Authentication.Sessions; -using ErrorOr; -using Microsoft.AspNetCore.Http; - -namespace Modgud.Tests.Unit.Authentication.Sessions; - -/// -/// Pins : pulls IP + UA out of the -/// , hands them to the session service, and swallows -/// failures (a tracking blip must NEVER bring down a login). -/// -public class SessionTrackerTests -{ - private sealed class CapturingSessionService : ISessionService - { - public Guid? CapturedUserId { get; private set; } - public string? CapturedIp { get; private set; } - public string? CapturedUa { get; private set; } - public int CallCount { get; private set; } - public Func>>? Behaviour { get; set; } - - public Task> CreateSessionAsync(Guid userId, string? ipAddress, string? userAgent, CancellationToken ct = default) - { - CallCount++; - CapturedUserId = userId; - CapturedIp = ipAddress; - CapturedUa = userAgent; - return Behaviour?.Invoke() ?? Task.FromResult>(new UserSession { Id = Guid.NewGuid() }); - } - - public Task> GetSessionsAsync(Guid userId, Guid? currentSessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task> RevokeAllSessionsAsync(Guid userId, Guid? exceptSessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - public Task TouchSessionAsync(Guid sessionId, CancellationToken ct = default) - => throw new NotImplementedException(); - } - - [Fact] - public async Task Forwards_user_id_ip_and_user_agent_to_session_service() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1"); - ctx.Request.Headers.UserAgent = "TestAgent/1.0"; - var userId = Guid.NewGuid(); - - await SessionTracker.RecordLoginAsync(svc, ctx, userId); - - Assert.Equal(1, svc.CallCount); - Assert.Equal(userId, svc.CapturedUserId); - Assert.Equal("10.0.0.1", svc.CapturedIp); - Assert.Equal("TestAgent/1.0", svc.CapturedUa); - } - - [Fact] - public async Task Forwards_null_ip_when_remote_address_missing() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - // Connection.RemoteIpAddress not set → null.toString() → null - ctx.Request.Headers.UserAgent = "ua"; - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - Assert.Null(svc.CapturedIp); - Assert.Equal("ua", svc.CapturedUa); - } - - [Fact] - public async Task Forwards_empty_user_agent_when_header_missing() - { - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - // StringValues.ToString() of a missing header is "" — pin so a future change - // to "null when missing" surfaces here. - Assert.Equal(string.Empty, svc.CapturedUa); - } - - [Fact] - public async Task Swallows_exceptions_thrown_by_session_service() - { - var svc = new CapturingSessionService - { - Behaviour = () => throw new InvalidOperationException("Marten down"), - }; - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - - // Must NOT throw — login is the caller, and a tracking failure must not - // reach the user. - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid()); - - Assert.Equal(1, svc.CallCount); - } - - [Fact] - public async Task Forwards_cancellation_token_through() - { - // The token isn't captured by the fake but we still exercise the path — - // make sure passing one doesn't trip up the helper. - var svc = new CapturingSessionService(); - var ctx = new DefaultHttpContext(); - ctx.Connection.RemoteIpAddress = IPAddress.Loopback; - using var cts = new CancellationTokenSource(); - - await SessionTracker.RecordLoginAsync(svc, ctx, Guid.NewGuid(), cts.Token); - - Assert.Equal(1, svc.CallCount); - } -} diff --git a/src/frontend-vue/src/models/ScheduledJob.ts b/src/frontend-vue/src/models/ScheduledJob.ts index 33258e2b..53400a0b 100644 --- a/src/frontend-vue/src/models/ScheduledJob.ts +++ b/src/frontend-vue/src/models/ScheduledJob.ts @@ -36,6 +36,8 @@ export interface ScheduledJobDto { Name: string Description?: string | null Kind: 'System' | 'Script' + /** Ownership: independent per realm, or the single Control-Plane system job. */ + Scope: 'Realm' | 'System' /** Effective cron — override if present, else registration default. */ EffectiveCron: string DefaultCron: string diff --git a/src/frontend-vue/src/models/application.ts b/src/frontend-vue/src/models/application.ts index 5fee18b9..6c678edd 100644 --- a/src/frontend-vue/src/models/application.ts +++ b/src/frontend-vue/src/models/application.ts @@ -99,6 +99,11 @@ export interface ApplicationGrantOverrideDto { RefreshTokenLifetimeDays?: number | null } +export interface ApplicationClientSessionsDto { + IdleLifetimeDays?: number | null + AbsoluteLifetimeDays?: number | null +} + export interface ApplicationDcrOverrideDto extends ApplicationGrantOverrideDto { GcTtlDays?: number | null PerIpRateLimitPerHour?: number | null @@ -120,6 +125,7 @@ export interface ApplicationSettingsDto { EmailBranding?: ApplicationEmailBrandingDto | null SelfRegistration?: ApplicationSelfRegistrationOverrideDto | null NativeGrants?: ApplicationGrantOverrideDto | null + ClientSessions?: ApplicationClientSessionsDto | null Dcr?: ApplicationDcrOverrideDto | null Cimd?: ApplicationGrantOverrideDto | null RegistrationFields?: ApplicationRegistrationFieldsOverrideDto | null diff --git a/src/frontend-vue/src/models/oauth.ts b/src/frontend-vue/src/models/oauth.ts index 93e51faf..96b577be 100644 --- a/src/frontend-vue/src/models/oauth.ts +++ b/src/frontend-vue/src/models/oauth.ts @@ -32,6 +32,8 @@ export interface OAuthClientDto { AccessTokenLifetime?: number | null AuthorizationCodeLifetime?: number | null SlidingRefreshTokenLifetime?: number | null + ClientSessionIdleLifetime?: number | null + ClientSessionAbsoluteLifetime?: number | null AlwaysSendClientClaims: boolean UpdateAccessTokenClaimsOnRefresh: boolean ClientClaimsPrefix?: string | null @@ -105,12 +107,18 @@ export interface CreateOAuthClientDto { RequireConsent?: boolean AllowedGrantTypes?: string[] AllowedCorsOrigins?: string[] + IdentityTokenLifetime?: number | null + AccessTokenLifetime?: number | null + AuthorizationCodeLifetime?: number | null + SlidingRefreshTokenLifetime?: number | null /** RFC 9126 — require this client to use Pushed Authorization Requests. Off by default. */ RequirePushedAuthorizationRequests?: boolean /** RFC 9449 (#118) — require this client to present a DPoP proof at the token endpoint. Off by default. */ RequireDpop?: boolean /** RFC 9449 §8-9 (#118) — require this client's DPoP proofs to carry a server-issued nonce. Off by default. */ RequireDpopNonce?: boolean + ClientSessionIdleLifetime?: number | null + ClientSessionAbsoluteLifetime?: number | null /** ADR-0009 — admin-set per-client WebAuthn RP ID. Blank = realm-scoped. */ WebAuthnRpId?: string | null /** @@ -139,6 +147,10 @@ export interface UpdateOAuthClientDto { AccessTokenLifetime?: number | null AuthorizationCodeLifetime?: number | null SlidingRefreshTokenLifetime?: number | null + ClientSessionIdleLifetime?: number | null + ClientSessionAbsoluteLifetime?: number | null + ClearClientSessionIdleLifetime?: boolean + ClearClientSessionAbsoluteLifetime?: boolean Claims?: OAuthClientClaimDto[] | null Roles?: string[] | null /** RFC 9126 PAR-requirement patch: null/missing = no change, true/false sets it. */ diff --git a/src/frontend-vue/src/models/realmSettings.ts b/src/frontend-vue/src/models/realmSettings.ts index fe8bf1da..f1250ab4 100644 --- a/src/frontend-vue/src/models/realmSettings.ts +++ b/src/frontend-vue/src/models/realmSettings.ts @@ -8,6 +8,8 @@ export interface RealmSettingsDto { Dcr: DcrSettingsDto Cimd: CimdSettingsDto NativeGrants: NativeGrantSettingsDto + BrowserSessions: BrowserSessionPolicyDto + ClientSessions: ClientSessionPolicyDto AuthRateLimits: AuthRateLimitsDto Branding: BrandingSettingsDto RegistrationFields: RegistrationFieldsSettingsDto @@ -22,12 +24,36 @@ export interface UpdateRealmSettingsDto { Dcr?: UpdateDcrSettingsDto | null Cimd?: UpdateCimdSettingsDto | null NativeGrants?: UpdateNativeGrantSettingsDto | null + BrowserSessions?: UpdateBrowserSessionPolicyDto | null + ClientSessions?: UpdateClientSessionPolicyDto | null AuthRateLimits?: UpdateAuthRateLimitsDto | null Branding?: UpdateBrandingSettingsDto | null RegistrationFields?: UpdateRegistrationFieldsSettingsDto | null Deletion?: UpdateDeletionSettingsDto | null } +export interface BrowserSessionPolicyDto { + IdleLifetimeMinutes: number + AbsoluteLifetimeMinutes: number + AllowRememberMe: boolean +} + +export interface UpdateBrowserSessionPolicyDto { + IdleLifetimeMinutes?: number + AbsoluteLifetimeMinutes?: number + AllowRememberMe?: boolean +} + +export interface ClientSessionPolicyDto { + IdleLifetimeDays: number + AbsoluteLifetimeDays: number +} + +export interface UpdateClientSessionPolicyDto { + IdleLifetimeDays?: number + AbsoluteLifetimeDays?: number +} + // Per-realm policy for which identity fields are required when an account is // created. Email is always required and is not represented. Each value is one // of 'Off' | 'Optional' | 'Required'. Default (unconfigured) = all Optional. diff --git a/src/frontend-vue/src/models/session.ts b/src/frontend-vue/src/models/session.ts index e7d190a8..0801cc18 100644 --- a/src/frontend-vue/src/models/session.ts +++ b/src/frontend-vue/src/models/session.ts @@ -16,4 +16,21 @@ export interface SessionDto { export interface SessionListDto { Sessions: SessionDto[] + ClientSessions: ClientSessionDto[] +} + +export interface ClientSessionDto { + Id: string + ClientId: string + ClientDisplayName?: string | null + IpAddress?: string | null + Browser?: string | null + BrowserVersion?: string | null + OperatingSystem?: string | null + OsVersion?: string | null + DeviceType?: string | null + CreatedAt: string + LastActiveAt: string + ExpiresAt: string + AbsoluteExpiresAt: string } diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index a68308e0..e8197034 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -31,6 +31,10 @@ import type { UpdateCimdSettingsDto, NativeGrantSettingsDto, UpdateNativeGrantSettingsDto, + BrowserSessionPolicyDto, + UpdateBrowserSessionPolicyDto, + ClientSessionPolicyDto, + UpdateClientSessionPolicyDto, AuthRateLimitsDto, UpdateAuthRateLimitsDto, DeletionSettingsDto, @@ -55,7 +59,7 @@ watch(language, () => ui.set((ctx) => { ctx.content.hasSubNav = true }), { immediate: true }) -type TabId = 'self-registration' | 'registration-fields' | 'dcr' | 'cimd' | 'native-grants' | 'auth-rate-limits' | 'deletion' | 'signing-keys' | 'pages' +type TabId = 'self-registration' | 'registration-fields' | 'sessions' | 'dcr' | 'cimd' | 'native-grants' | 'auth-rate-limits' | 'deletion' | 'signing-keys' | 'pages' const activeTab = ref('self-registration') const canRotateSigningKey = computed(() => authStore.hasPermission('realm-settings:write')) @@ -212,6 +216,20 @@ function nativeGrantsFromDto(d: NativeGrantSettingsDto): NativeGrantFormState { } } +// ── Authoritative browser + native-client session policies ─────────── +const browserSessionsForm = ref({ + IdleLifetimeMinutes: 30 * 24 * 60, + AbsoluteLifetimeMinutes: 180 * 24 * 60, + AllowRememberMe: true, +}) +const originalBrowserSessions = ref(null) + +const clientSessionsForm = ref({ + IdleLifetimeDays: 30, + AbsoluteLifetimeDays: 365, +}) +const originalClientSessions = ref(null) + // ── Auth rate-limit form state (per-IP ceilings, configurable per realm) ── type RateLimitPolicyKey = 'NativeOtp' | 'MagicLink' | 'PasswordReset' | 'EmailOtp' @@ -352,6 +370,10 @@ onMounted(async () => { cimdForm.value = cimdFromDto(dto.Cimd) originalNativeGrants.value = dto.NativeGrants nativeGrantsForm.value = nativeGrantsFromDto(dto.NativeGrants) + originalBrowserSessions.value = dto.BrowserSessions + browserSessionsForm.value = { ...dto.BrowserSessions } + originalClientSessions.value = dto.ClientSessions + clientSessionsForm.value = { ...dto.ClientSessions } originalAuthRateLimits.value = dto.AuthRateLimits authRateLimitsForm.value = authRateLimitsFromDto(dto.AuthRateLimits) originalDeletion.value = dto.Deletion @@ -454,6 +476,36 @@ function buildNativeGrantsPatch(): UpdateNativeGrantSettingsDto | undefined { return Object.keys(patch).length === 0 ? undefined : patch } +function buildBrowserSessionsPatch(): UpdateBrowserSessionPolicyDto | undefined { + const orig = originalBrowserSessions.value + if (!orig) return undefined + const cur = browserSessionsForm.value + const patch: UpdateBrowserSessionPolicyDto = {} + + if (cur.IdleLifetimeMinutes !== orig.IdleLifetimeMinutes) + patch.IdleLifetimeMinutes = cur.IdleLifetimeMinutes + if (cur.AbsoluteLifetimeMinutes !== orig.AbsoluteLifetimeMinutes) + patch.AbsoluteLifetimeMinutes = cur.AbsoluteLifetimeMinutes + if (cur.AllowRememberMe !== orig.AllowRememberMe) + patch.AllowRememberMe = cur.AllowRememberMe + + return Object.keys(patch).length === 0 ? undefined : patch +} + +function buildClientSessionsPatch(): UpdateClientSessionPolicyDto | undefined { + const orig = originalClientSessions.value + if (!orig) return undefined + const cur = clientSessionsForm.value + const patch: UpdateClientSessionPolicyDto = {} + + if (cur.IdleLifetimeDays !== orig.IdleLifetimeDays) + patch.IdleLifetimeDays = cur.IdleLifetimeDays + if (cur.AbsoluteLifetimeDays !== orig.AbsoluteLifetimeDays) + patch.AbsoluteLifetimeDays = cur.AbsoluteLifetimeDays + + return Object.keys(patch).length === 0 ? undefined : patch +} + function buildAuthRateLimitsPatch(): UpdateAuthRateLimitsDto | undefined { const orig = originalAuthRateLimits.value if (!orig) return undefined @@ -502,10 +554,12 @@ async function save() { const dcrPatch = buildDcrPatch() const cimdPatch = buildCimdPatch() const nativeGrantsPatch = buildNativeGrantsPatch() + const browserSessionsPatch = buildBrowserSessionsPatch() + const clientSessionsPatch = buildClientSessionsPatch() const authRateLimitsPatch = buildAuthRateLimitsPatch() const deletionPatch = buildDeletionPatch() const regFieldsPatch = buildRegFieldsPatch() - if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !authRateLimitsPatch && !deletionPatch && !regFieldsPatch) { + if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !browserSessionsPatch && !clientSessionsPatch && !authRateLimitsPatch && !deletionPatch && !regFieldsPatch) { savedFlash.value = true setTimeout(() => { savedFlash.value = false }, 1200) return @@ -518,6 +572,8 @@ async function save() { Dcr?: UpdateDcrSettingsDto Cimd?: UpdateCimdSettingsDto NativeGrants?: UpdateNativeGrantSettingsDto + BrowserSessions?: UpdateBrowserSessionPolicyDto + ClientSessions?: UpdateClientSessionPolicyDto AuthRateLimits?: UpdateAuthRateLimitsDto Deletion?: UpdateDeletionSettingsDto RegistrationFields?: UpdateRegistrationFieldsSettingsDto @@ -526,6 +582,8 @@ async function save() { if (dcrPatch) payload.Dcr = dcrPatch if (cimdPatch) payload.Cimd = cimdPatch if (nativeGrantsPatch) payload.NativeGrants = nativeGrantsPatch + if (browserSessionsPatch) payload.BrowserSessions = browserSessionsPatch + if (clientSessionsPatch) payload.ClientSessions = clientSessionsPatch if (authRateLimitsPatch) payload.AuthRateLimits = authRateLimitsPatch if (deletionPatch) payload.Deletion = deletionPatch if (regFieldsPatch) payload.RegistrationFields = regFieldsPatch @@ -538,6 +596,10 @@ async function save() { cimdForm.value = cimdFromDto(updated.Cimd) originalNativeGrants.value = updated.NativeGrants nativeGrantsForm.value = nativeGrantsFromDto(updated.NativeGrants) + originalBrowserSessions.value = updated.BrowserSessions + browserSessionsForm.value = { ...updated.BrowserSessions } + originalClientSessions.value = updated.ClientSessions + clientSessionsForm.value = { ...updated.ClientSessions } originalAuthRateLimits.value = updated.AuthRateLimits authRateLimitsForm.value = authRateLimitsFromDto(updated.AuthRateLimits) originalDeletion.value = updated.Deletion @@ -581,6 +643,9 @@ async function rotateSigningKey() { {{ t('admin.realmSettings.tabs.registrationFields', {}, 'Pflichtfelder') }} + + {{ t('admin.realmSettings.tabs.sessions', {}, 'Sessions') }} + {{ t('admin.realmSettings.tabs.dcr', {}, 'Dynamic Client Registration') }} @@ -743,6 +808,65 @@ async function rotateSigningKey() {
+ +
+
+
+

+ {{ t('admin.realmSettings.sessions.browser.title', {}, 'Browser and SSO sessions') }} +

+

+ {{ t('admin.realmSettings.sessions.browser.hint', {}, 'These sessions back the signed application cookie. Idle lifetime slides while the browser is used; absolute lifetime never slides.') }} +

+
+
+ + + + + + +
+ +
+ +
+
+

+ {{ t('admin.realmSettings.sessions.client.title', {}, 'Native app and OAuth client sessions') }} +

+

+ {{ t('admin.realmSettings.sessions.client.hint', {}, 'This is the realm default for refresh-token-backed sessions. Apps and individual OAuth clients may override it. Up to 3650 days (10 years) is supported; access tokens remain short-lived independently.') }} +

+
+
+ + + + + + +
+
+ +
+ + {{ t('common.save', {}, 'Save') }} + +
+
+
+

diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue index 1494935d..dc2f7cca 100644 --- a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -25,7 +25,7 @@ const props = defineProps<{ const groupStore = useGroupStore() const appConfig = useAppConfigStore() -const activeTab = ref<'origin' | 'registration' | 'grants' | 'oauth' | 'pages'>('origin') +const activeTab = ref<'origin' | 'registration' | 'sessions' | 'grants' | 'oauth' | 'pages'>('origin') const groupOptions = ref<{ value: string; label: string }[]>([]) @@ -52,6 +52,7 @@ const f = reactive({ firstname: '' as '' | 'Off' | 'Optional' | 'Required', lastname: '' as '' | 'Off' | 'Optional' | 'Required', }, + clientSessions: { override: false, idle: '', absolute: '' }, nativeGrants: { override: false, enabled: false, access: '', refresh: '' }, dcr: { override: false, enabled: false, access: '', refresh: '', @@ -95,6 +96,7 @@ function resetForm() { f.selfReg.termsOfServiceUrl = ''; f.selfReg.privacyPolicyUrl = '' f.registrationFields.override = false; f.registrationFields.username = '' f.registrationFields.firstname = ''; f.registrationFields.lastname = '' + f.clientSessions.override = false; f.clientSessions.idle = ''; f.clientSessions.absolute = '' f.nativeGrants.override = false; f.nativeGrants.enabled = false; f.nativeGrants.access = ''; f.nativeGrants.refresh = '' f.dcr.override = false; f.dcr.enabled = false; f.dcr.access = ''; f.dcr.refresh = '' f.dcr.reservedNames = []; f.dcr.perIp = ''; f.dcr.perRealm = '' @@ -139,6 +141,11 @@ function populate(s?: ApplicationSettingsDto | null) { f.nativeGrants.access = numStr(s.NativeGrants.AccessTokenLifetimeMinutes) f.nativeGrants.refresh = numStr(s.NativeGrants.RefreshTokenLifetimeDays) } + if (s.ClientSessions) { + f.clientSessions.override = true + f.clientSessions.idle = numStr(s.ClientSessions.IdleLifetimeDays) + f.clientSessions.absolute = numStr(s.ClientSessions.AbsoluteLifetimeDays) + } if (s.Dcr) { f.dcr.override = true f.dcr.enabled = s.Dcr.Enabled ?? false @@ -191,6 +198,9 @@ function build(): ApplicationSettingsDto { NativeGrants: f.nativeGrants.override ? { Enabled: f.nativeGrants.enabled, AccessTokenLifetimeMinutes: parseNum(f.nativeGrants.access), RefreshTokenLifetimeDays: parseNum(f.nativeGrants.refresh) } : null, + ClientSessions: f.clientSessions.override + ? { IdleLifetimeDays: parseNum(f.clientSessions.idle), AbsoluteLifetimeDays: parseNum(f.clientSessions.absolute) } + : null, Dcr: f.dcr.override ? { Enabled: f.dcr.enabled, @@ -288,6 +298,7 @@ watch(() => [activeTab.value, props.applicationId] as const, ([tab]) => { {{ t('admin.appSettings.tabs.origin', {}, 'Origin & Branding') }} {{ t('admin.appSettings.tabs.registration', {}, 'Registrierung') }} + {{ t('admin.appSettings.tabs.sessions', {}, 'Sessions') }} {{ t('admin.appSettings.tabs.grants', {}, 'Native Grants') }} {{ t('admin.appSettings.tabs.oauth', {}, 'OAuth (DCR/CIMD)') }} @@ -372,6 +383,26 @@ watch(() => [activeTab.value, props.applicationId] as const, ([tab]) => {

+ +
+ + {{ t('admin.appSettings.sessions.hint', {}, 'Override the realm default for refresh-token-backed sessions in this app. Individual OAuth clients can override this again. Access-token lifetime is configured separately and remains short.') }} + + + +
+
diff --git a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue index 58c15b8c..3a7a4234 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue @@ -198,6 +198,9 @@ interface FormState { AccessTokenLifetime: number | null AuthorizationCodeLifetime: number | null SlidingRefreshTokenLifetime: number | null + /** Per-client native/OAuth session override in seconds. Null = App/Realm policy. */ + ClientSessionIdleLifetime: number | null + ClientSessionAbsoluteLifetime: number | null /** ADR-0009 — admin-set per-client WebAuthn RP ID for native passkeys. Empty = realm-scoped. */ WebAuthnRpId: string /** Selected App.Ids. Empty list = realm-wide. */ @@ -246,6 +249,8 @@ function emptyForm(): FormState { AccessTokenLifetime: null, AuthorizationCodeLifetime: null, SlidingRefreshTokenLifetime: null, + ClientSessionIdleLifetime: null, + ClientSessionAbsoluteLifetime: null, WebAuthnRpId: '', AppIds: [], } @@ -280,6 +285,8 @@ function fromDto(dto: OAuthClientDto): FormState { AccessTokenLifetime: dto.AccessTokenLifetime ?? null, AuthorizationCodeLifetime: dto.AuthorizationCodeLifetime ?? null, SlidingRefreshTokenLifetime: dto.SlidingRefreshTokenLifetime ?? null, + ClientSessionIdleLifetime: dto.ClientSessionIdleLifetime ?? null, + ClientSessionAbsoluteLifetime: dto.ClientSessionAbsoluteLifetime ?? null, WebAuthnRpId: dto.WebAuthnRpId ?? '', AppIds: [...(dto.AppIds ?? [])], } @@ -398,6 +405,12 @@ function buildCreateDto(): CreateOAuthClientDto { RequirePushedAuthorizationRequests: form.value.RequirePushedAuthorizationRequests, RequireDpop: form.value.RequireDpop, RequireDpopNonce: form.value.RequireDpopNonce, + IdentityTokenLifetime: form.value.IdentityTokenLifetime, + AccessTokenLifetime: form.value.AccessTokenLifetime, + AuthorizationCodeLifetime: form.value.AuthorizationCodeLifetime, + SlidingRefreshTokenLifetime: form.value.SlidingRefreshTokenLifetime, + ClientSessionIdleLifetime: form.value.ClientSessionIdleLifetime, + ClientSessionAbsoluteLifetime: form.value.ClientSessionAbsoluteLifetime, RedirectUris: [...form.value.RedirectUris], PostLogoutRedirectUris: [...form.value.PostLogoutRedirectUris], AllowedGrantTypes: [...form.value.AllowedGrantTypes], @@ -434,6 +447,10 @@ function buildUpdateDto(): UpdateOAuthClientDto { AccessTokenLifetime: form.value.AccessTokenLifetime, AuthorizationCodeLifetime: form.value.AuthorizationCodeLifetime, SlidingRefreshTokenLifetime: form.value.SlidingRefreshTokenLifetime, + ClientSessionIdleLifetime: form.value.ClientSessionIdleLifetime, + ClientSessionAbsoluteLifetime: form.value.ClientSessionAbsoluteLifetime, + ClearClientSessionIdleLifetime: form.value.ClientSessionIdleLifetime === null, + ClearClientSessionAbsoluteLifetime: form.value.ClientSessionAbsoluteLifetime === null, // ADR-0009 PATCH: send the trimmed value verbatim — "" clears back to // realm-scoped, a host sets the per-client RP ID. WebAuthnRpId: form.value.WebAuthnRpId.trim(), @@ -567,7 +584,7 @@ async function copySecret() { {{ t('admin.oauthClients.tabs.scopes', {}, 'Scopes') }} {{ t('admin.oauthClients.tabs.grants', {}, 'Grants') }} {{ t('admin.oauthClients.tabs.urls', {}, 'URLs') }} - {{ t('admin.oauthClients.tabs.lifetimes', {}, 'Token-Laufzeiten') }} + {{ t('admin.oauthClients.tabs.lifetimes', {}, 'Lifetimes') }} {{ t('admin.oauthClients.tabs.dcr', {}, 'Registration Info') }} @@ -677,7 +694,7 @@ async function copySecret() {

- {{ t('admin.oauthClients.lifetimesHint', {}, 'Values in seconds. Empty = the IdP\'s default.') }} + {{ t('admin.oauthClients.lifetimesHint', {}, 'Values in seconds. Empty token values use the IdP default; empty session values inherit from the linked App and then the Realm.') }}

@@ -693,6 +710,17 @@ async function copySecret() {
+ + {{ t('admin.oauthClients.clientSessionsHint', {}, 'Client sessions are authoritative for refresh-token use. Idle lifetime slides when the app refreshes; absolute lifetime never slides. Maximum: 315,360,000 seconds (3650 days / 10 years). Access tokens remain short-lived independently.') }} + +
+ + + + + + +
diff --git a/src/frontend-vue/src/views/dashboard/DashboardView.vue b/src/frontend-vue/src/views/dashboard/DashboardView.vue index 01d1d751..095b4a1e 100644 --- a/src/frontend-vue/src/views/dashboard/DashboardView.vue +++ b/src/frontend-vue/src/views/dashboard/DashboardView.vue @@ -7,7 +7,7 @@ import { useUI } from '@/composables/useUI' import { useHttpClient } from '@/composables/useHttpClient' import { useAuthStore } from '@/stores/auth.store' import { useLoginProviderStore } from '@/stores/loginProvider.store' -import type { SessionDto, SessionListDto } from '@/models/session' +import type { ClientSessionDto, SessionDto, SessionListDto } from '@/models/session' import type { UserDto } from '@/models/user' import type { KpiTile } from './kpiTile' import KpiCard from './KpiCard.vue' @@ -127,14 +127,16 @@ function goToProfileSecurity() { // ─── Aktive Sessions ────────────────────────────────────────────────────── const sessionsHttp = useHttpClient('/api/auth/sessions') -const sessions = ref([]) +const browserSessions = ref([]) +const clientSessions = ref([]) const sessionsLoading = ref(true) const sessionsError = ref(false) async function loadSessions() { try { const res = await sessionsHttp.get() - sessions.value = res.Sessions ?? [] + browserSessions.value = res.Sessions ?? [] + clientSessions.value = res.ClientSessions ?? [] } catch { sessionsError.value = true } finally { @@ -142,10 +144,21 @@ async function loadSessions() { } } +type DashboardSession = + | (SessionDto & { Kind: 'Browser' }) + | (ClientSessionDto & { Kind: 'Client' }) + +const sessions = computed(() => [ + ...browserSessions.value.map(s => ({ ...s, Kind: 'Browser' as const })), + ...clientSessions.value.map(s => ({ ...s, Kind: 'Client' as const })), +].sort((a, b) => new Date(b.LastActiveAt).getTime() - new Date(a.LastActiveAt).getTime())) const topSessions = computed(() => sessions.value.slice(0, 3)) const extraSessionCount = computed(() => Math.max(0, sessions.value.length - 3)) -function deviceLabel(s: SessionDto): string { +function deviceLabel(s: DashboardSession): string { + if (s.Kind === 'Client') + return s.ClientDisplayName || s.ClientId + // KPI-style "Browser auf Gerät" — the screenshot's row label uses // "Chrome auf Windows" rather than the older "Browser · OS" form. const browser = s.Browser || t('dashboard.sessions.unknownBrowser', {}, 'Browser') @@ -508,14 +521,17 @@ onMounted(() => { :key="s.Id" type="button" class="list-row" - :class="{ 'list-row--strong': s.IsCurrent }" + :class="{ 'list-row--strong': s.Kind === 'Browser' && s.IsCurrent }" @click="goToProfileSessions" > {{ deviceLabel(s) }} - + {{ t('dashboard.sessions.thisDevice', {}, 'This Device') }} + + {{ t('dashboard.sessions.app', {}, 'App') }} + {{ relativeTime(s.LastActiveAt) }} diff --git a/src/frontend-vue/src/views/profile/ProfileView.vue b/src/frontend-vue/src/views/profile/ProfileView.vue index c4743e37..3e962906 100644 --- a/src/frontend-vue/src/views/profile/ProfileView.vue +++ b/src/frontend-vue/src/views/profile/ProfileView.vue @@ -32,9 +32,10 @@ watch(language, () => ui.set((ctx) => { const activeSection = ref<'account' | 'security' | 'sessions' | 'privacy' | 'preferences'>('account') // ─── Sessions self-service ──────────────────────────────────────────────── -import type { SessionDto, SessionListDto } from '@/models/session' +import type { ClientSessionDto, SessionDto, SessionListDto } from '@/models/session' const sessionsHttp = useHttpClient('/api/auth/sessions') const sessions = ref([]) +const clientSessions = ref([]) const sessionsLoading = ref(false) const sessionsError = ref('') const revokingSessionId = ref(null) @@ -47,6 +48,7 @@ async function loadSessions() { try { const res = await sessionsHttp.get() sessions.value = res.Sessions ?? [] + clientSessions.value = res.ClientSessions ?? [] } catch (e: any) { sessionsError.value = e?.message ?? String(e) } finally { @@ -67,8 +69,21 @@ async function revokeSession(id: string) { } } +async function revokeClientSession(id: string) { + if (!confirm(t('profile.sessions.confirmRevokeClient', {}, 'Really sign this app out?'))) return + revokingSessionId.value = id + try { + await sessionsHttp.addPath('client').addPath(id).delete() + clientSessions.value = clientSessions.value.filter((s) => s.Id !== id) + } catch (e: any) { + sessionsError.value = e?.message ?? String(e) + } finally { + revokingSessionId.value = null + } +} + async function revokeAllSessions() { - if (!confirm(t('profile.sessions.confirmRevokeAll', {}, 'Really sign out everywhere? You\'ll be signed in again.'))) return + if (!confirm(t('profile.sessions.confirmRevokeAll', {}, 'Really sign out everywhere? This browser and every connected app will have to sign in again.'))) return revokingAll.value = true try { await sessionsHttp.delete() @@ -170,14 +185,14 @@ async function cancelDeletion() { } } -function deviceIcon(s: SessionDto): string { +function deviceIcon(s: SessionDto | ClientSessionDto): string { const dt = (s.DeviceType ?? '').toLowerCase() if (dt.includes('mobile') || dt.includes('phone')) return 'smartphone' if (dt.includes('tablet')) return 'tablet' return 'monitor' } -function deviceLabel(s: SessionDto): string { +function deviceLabel(s: SessionDto | ClientSessionDto): string { const browser = [s.Browser, s.BrowserVersion].filter(Boolean).join(' ') const os = [s.OperatingSystem, s.OsVersion].filter(Boolean).join(' ') return [browser, os].filter(Boolean).join(' · ') || (s.DeviceType ?? t('profile.sessions.unknownDevice', {}, 'Unknown Device')) @@ -915,48 +930,92 @@ function onMfaSetupClose(enabled: boolean) {
{{ t('profile.sessions.revokeAll', {}, 'Sign out everywhere') }} -
+
{{ t('common.loading', {}, 'Loading...') }}
{{ sessionsError }}
-
+
{{ t('profile.sessions.none', {}, 'No sessions.') }}
-
-
- -
-
- {{ deviceLabel(s) }} - - {{ t('profile.sessions.current', {}, 'Current Session') }} - +
+
+

+ {{ t('profile.sessions.browserTitle', {}, 'Browser and SSO sessions') }} +

+

+ {{ t('profile.sessions.noBrowserSessions', {}, 'No browser sessions.') }} +

+
+
+ +
+
+ {{ deviceLabel(s) }} + + {{ t('profile.sessions.current', {}, 'Current Session') }} + +
+
+ IP: {{ s.IpAddress }} · + {{ t('profile.sessions.lastActive', {}, 'Last active:') }} + {{ new Date(s.LastActiveAt).toLocaleString() }} + · {{ t('profile.sessions.created', {}, 'Created:') }} + {{ new Date(s.CreatedAt).toLocaleDateString() }} +
+
+
-
- IP: {{ s.IpAddress }} · - {{ t('profile.sessions.lastActive', {}, 'Last active:') }} - {{ new Date(s.LastActiveAt).toLocaleString() }} - · {{ t('profile.sessions.created', {}, 'Created:') }} - {{ new Date(s.CreatedAt).toLocaleDateString() }} +
+
+ +
+

+ {{ t('profile.sessions.clientTitle', {}, 'Signed-in apps and devices') }} +

+

+ {{ t('profile.sessions.noClientSessions', {}, 'No native app sessions.') }} +

+
+
+ +
+
+ {{ s.ClientDisplayName || s.ClientId }} +
+
+ {{ deviceLabel(s) }} + + · {{ t('profile.sessions.lastActive', {}, 'Last active:') }} + {{ new Date(s.LastActiveAt).toLocaleString() }} + · {{ t('profile.sessions.absoluteExpiry', {}, 'Must sign in by:') }} + {{ new Date(s.AbsoluteExpiresAt).toLocaleDateString() }} +
+
+
- -
+
From 375f74407476e7d0c05e151ed18f882afe08d05b Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 16:45:28 +0200 Subject: [PATCH 04/34] feat: redesign resource server integration --- .../workflows/cd-publish-nuget-prerelease.yml | 32 +- .github/workflows/cd-release.yml | 14 +- SECURITY.md | 2 +- docs/admin/oauth-apis.md | 5 +- docs/admin/oauth-clients.md | 2 +- docs/concepts/apps-and-resource-access.md | 8 +- docs/concepts/permissions.md | 14 +- docs/contribute/local-ci.md | 2 +- docs/contribute/testing/automated-tests.md | 6 +- docs/getting-started/features.md | 8 +- docs/index.md | 4 +- docs/integrate/index.md | 2 +- docs/integrate/mcp-server.md | 2 +- docs/integrate/resource-server.md | 317 ++++++++++-------- docs/integrate/saas-walkthrough.md | 65 ++-- docs/operate/backend-architecture.md | 2 +- docs/operate/supply-chain.md | 4 +- docs/reference/oauth-api.md | 2 +- docs/roadmap.md | 2 +- .../Authorization/DpopIssuanceTests.cs | 2 +- .../Authorization/UserInfoPerAudienceTests.cs | 261 ++++++++++++-- .../Modgud.Api.Tests/Modgud.Api.Tests.csproj | 4 +- .../Dpop/DpopProofValidator.cs | 6 +- .../Dpop/DpopResourceValidator.cs | 2 +- .../Dpop/DpopValidationResult.cs | 4 +- .../Dpop/JwkThumbprint.cs | 6 +- .../Modgud.AspNetCore.ResourceServer.csproj} | 15 +- .../ModgudClaimsProjector.cs | 86 +++++ .../ModgudDpopJwtBearer.cs | 10 +- .../ModgudIntrospectionHandler.cs | 194 +++++++++++ .../ModgudPermissionExtensions.cs | 41 +++ .../ModgudResourceServerOptions.cs | 77 +++++ .../README.md | 145 ++++++++ .../ServiceCollectionExtensions.cs | 260 ++++++++++++++ .../Modgud.Authorization.csproj | 7 +- .../ModgudClaimsTransformation.cs | 142 -------- .../ModgudIntrospectionHandler.cs | 235 ------------- .../Modgud.Client.AspNetCore/ModgudOptions.cs | 53 --- .../ModgudReferenceTokenOptions.cs | 69 ---- src/dotnet/Modgud.Client.AspNetCore/README.md | 155 --------- .../RequiresModgudPermissionFilter.cs | 71 ---- .../ServiceCollectionExtensions.cs | 129 ------- .../UserInfoEnricher.cs | 189 ----------- .../OpenIddict/Dpop/DpopProofValidator.cs | 2 +- .../OpenIddict/Dpop/JwkThumbprint.cs | 2 +- .../Modgud.Permissions.Abstractions.csproj | 2 +- .../PermissionEvaluator.cs | 10 +- .../PermissionsAbstractionsPurityTests.cs | 17 +- .../AspNetCore/IntrospectionHandlerTests.cs | 175 ---------- .../ModgudClaimsTransformationTests.cs | 261 -------------- .../RequiresModgudPermissionFilterTests.cs | 156 --------- .../AspNetCore/UserInfoEnricherTests.cs | 277 --------------- .../Modgud.Tests.Unit.csproj | 2 +- .../DpopJwtBearerBindingTests.cs | 6 +- .../DpopResourceValidationTests.cs | 8 +- .../IntrospectionHandlerTests.cs | 134 ++++++++ .../ModgudClaimsProjectorTests.cs | 109 ++++++ .../ModgudPermissionExtensionsTests.cs | 27 ++ .../ResourceServerRegistrationTests.cs | 232 +++++++++++++ src/dotnet/Modgud.slnx | 2 +- .../Modgud.TestApps.ResourceApi.csproj | 2 +- .../Modgud.TestApps.ResourceApi/Program.cs | 110 +++--- src/landing-page/src/pages/index.astro | 6 +- 63 files changed, 1899 insertions(+), 2297 deletions(-) rename src/dotnet/{Modgud.Client.AspNetCore => Modgud.AspNetCore.ResourceServer}/Dpop/DpopProofValidator.cs (98%) rename src/dotnet/{Modgud.Client.AspNetCore => Modgud.AspNetCore.ResourceServer}/Dpop/DpopResourceValidator.cs (98%) rename src/dotnet/{Modgud.Client.AspNetCore => Modgud.AspNetCore.ResourceServer}/Dpop/DpopValidationResult.cs (96%) rename src/dotnet/{Modgud.Client.AspNetCore => Modgud.AspNetCore.ResourceServer}/Dpop/JwkThumbprint.cs (96%) rename src/dotnet/{Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj => Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj} (83%) create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs rename src/dotnet/{Modgud.Client.AspNetCore => Modgud.AspNetCore.ResourceServer}/ModgudDpopJwtBearer.cs (96%) create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/README.md create mode 100644 src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/README.md delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs delete mode 100644 src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs delete mode 100644 src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs delete mode 100644 src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs delete mode 100644 src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs delete mode 100644 src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs rename src/dotnet/Modgud.Tests.Unit/{Client/AspNetCore => ResourceServer}/DpopJwtBearerBindingTests.cs (98%) rename src/dotnet/Modgud.Tests.Unit/{Client/AspNetCore => ResourceServer}/DpopResourceValidationTests.cs (95%) create mode 100644 src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs diff --git a/.github/workflows/cd-publish-nuget-prerelease.yml b/.github/workflows/cd-publish-nuget-prerelease.yml index a29b00f7..799b7dce 100644 --- a/.github/workflows/cd-publish-nuget-prerelease.yml +++ b/.github/workflows/cd-publish-nuget-prerelease.yml @@ -1,6 +1,6 @@ name: CD - Publish NuGet Prerelease -# Builds and publishes a prerelease of Modgud.Client.AspNetCore to +# Builds and publishes a prerelease of Modgud.AspNetCore.ResourceServer to # nuget.org. Mirrors the cocoar.configuration workflow pattern so the # release plumbing is consistent across the org. # @@ -23,7 +23,7 @@ name: CD - Publish NuGet Prerelease # Trigger this when: # - You need a specific prerelease build to test in a downstream # consumer app -# - An external user asked for "the latest" of the client library +# - An external user asked for "the latest" resource-server package # - Pre-stable release-candidate testing # # Otherwise leave it alone. The version still ships via the @@ -50,7 +50,7 @@ concurrency: jobs: test: - name: Test client lib + name: Test resource-server package runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -67,24 +67,24 @@ jobs: - name: Setup .NET uses: ./.github/actions/setup-dotnet - - name: Restore client lib + unit tests + - name: Restore resource-server package + unit tests run: | - dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj dotnet restore Modgud.Tests.Unit/Modgud.Tests.Unit.csproj working-directory: ./src/dotnet - - name: Build client lib + unit tests + - name: Build resource-server package + unit tests run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj -c Release --no-restore + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj -c Release --no-restore dotnet build Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-restore working-directory: ./src/dotnet - # Filter to the Client.AspNetCore tests so we don't pull in + # Filter to the ResourceServer tests so we don't pull in # integration tests that need Docker/Postgres (those live in - # Modgud.Api.Tests and aren't relevant for a client-lib + # Modgud.Api.Tests and aren't relevant for this package # publish gate). - - name: Test client lib - run: dotnet test Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~Client" + - name: Test resource-server package + run: dotnet test Modgud.Tests.Unit/Modgud.Tests.Unit.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~ResourceServer" working-directory: ./src/dotnet publish-prerelease: @@ -134,12 +134,12 @@ jobs: echo "Calculated prerelease version: $VERSION" - name: Restore - run: dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + run: dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj working-directory: ./src/dotnet - name: Build run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-restore \ -p:Version=$PACKAGE_VERSION \ -p:AssemblyVersion=${{ steps.dotnet.outputs.assembly-semver }} \ @@ -149,7 +149,7 @@ jobs: - name: Pack (incl. symbols) run: | - dotnet pack Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet pack Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-build \ -p:Version=$PACKAGE_VERSION \ -p:ContinuousIntegrationBuild=true \ @@ -189,10 +189,10 @@ jobs: - name: Summary run: | { - echo "### Modgud.Client.AspNetCore — $PACKAGE_VERSION (prerelease)" + echo "### Modgud.AspNetCore.ResourceServer — $PACKAGE_VERSION (prerelease)" echo "" if [ "${{ github.ref }}" = "refs/heads/develop" ]; then - echo "**Pushed to:** https://www.nuget.org/packages/Modgud.Client.AspNetCore/$PACKAGE_VERSION" + echo "**Pushed to:** https://www.nuget.org/packages/Modgud.AspNetCore.ResourceServer/$PACKAGE_VERSION" else echo "**Artifact only** (feature branch — not pushed to nuget.org)." echo "Download the \`prerelease-packages-$PACKAGE_VERSION\` artifact and add it as a local source to test." diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 5a9f2e49..ebf4c6c2 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -40,7 +40,7 @@ name: CD - Release # Sibling workflows for staging / prerelease / editorial doc paths: # - cd-publish-staging-image.yml — pushes `:beta` / `:` moving # Docker tags (auto on develop push + manual) -# - cd-publish-nuget-prerelease.yml — prerelease NuGet for client-lib +# - cd-publish-nuget-prerelease.yml — prerelease resource-server NuGet # (manual workflow_dispatch) # - cd-deploy-docs.yml — editorial doc deploy to Shelf # between releases (manual) @@ -146,7 +146,7 @@ jobs: working-directory: ./src/dotnet pack-nuget: - name: Pack NuGet (Client.AspNetCore) + name: Pack NuGet (ResourceServer) needs: validate-version runs-on: ubuntu-latest permissions: @@ -165,12 +165,12 @@ jobs: uses: ./.github/actions/setup-dotnet - name: Restore - run: dotnet restore Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj + run: dotnet restore Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj working-directory: ./src/dotnet - name: Build (stable) run: | - dotnet build Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet build Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-restore \ -p:Version=${{ needs.validate-version.outputs.version }} \ -p:ContinuousIntegrationBuild=true @@ -178,7 +178,7 @@ jobs: - name: Pack (incl. symbols) run: | - dotnet pack Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj \ + dotnet pack Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj \ -c Release --no-build \ -p:Version=${{ needs.validate-version.outputs.version }} \ -p:ContinuousIntegrationBuild=true \ @@ -442,9 +442,9 @@ jobs: - name: Summary run: | { - echo "### NuGet — Modgud.Client.AspNetCore ${{ needs.validate-version.outputs.version }}" + echo "### NuGet — Modgud.AspNetCore.ResourceServer ${{ needs.validate-version.outputs.version }}" echo "" - echo "**URL:** https://www.nuget.org/packages/Modgud.Client.AspNetCore/${{ needs.validate-version.outputs.version }}" + echo "**URL:** https://www.nuget.org/packages/Modgud.AspNetCore.ResourceServer/${{ needs.validate-version.outputs.version }}" } >> "$GITHUB_STEP_SUMMARY" publish-docker: diff --git a/SECURITY.md b/SECURITY.md index 9d66c9aa..e53f1e9a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ thing. - The Modgud IdP itself — backend (`src/dotnet/Modgud.Api`, `Modgud.Authentication`, `Modgud.Authorization`, `Modgud.Domain`, `Modgud.Infrastructure`) and admin SPA (`src/frontend-vue/`). -- The `Modgud.Client.AspNetCore` NuGet package that downstream apps +- The `Modgud.AspNetCore.ResourceServer` NuGet package that downstream apps use to validate Modgud-issued tokens. - The official Docker image (`ghcr.io/cocoar-dev/modgud:*`). - The default configuration shipped in diff --git a/docs/admin/oauth-apis.md b/docs/admin/oauth-apis.md index 6c359af9..be328889 100644 --- a/docs/admin/oauth-apis.md +++ b/docs/admin/oauth-apis.md @@ -134,8 +134,9 @@ Client whose **Client ID equals its own audience** (this API's name — the RFC 8707 `resource=` value already carried in the token's `aud`), and authenticates the introspection call with that client's own credentials (sent as form-body parameters, so a URL-shaped audience id -works). The [.NET client library](/integrate/resource-server#reference-token-mode-opaque-tokens) -does this for you via `AddModgudReferenceTokenClient`. +works). The [.NET resource-server library](/integrate/resource-server#reference-token-mode) +does this through `AddModgudResourceServer` with +`TokenMode = ModgudTokenMode.OnlyReferenceToken`. ## Editing diff --git a/docs/admin/oauth-clients.md b/docs/admin/oauth-clients.md index 59a9b302..c43f7427 100644 --- a/docs/admin/oauth-clients.md +++ b/docs/admin/oauth-clients.md @@ -82,7 +82,7 @@ New clients default to **JWT**. Two options: | **JWT** (default) | Self-contained signed token — the claims are inside the token | The resource server validates it locally against the realm's signing key (JWKS); no callback to Modgud | | **Reference** | Opaque random string — carries no claims | The resource server must call `/connect/introspect` on every request to resolve it | -A resource server built with ASP.NET Core's `AddJwtBearer` expects a **JWT** — that's the right pick for the common case. Use **Reference** only when you specifically want every token resolvable/revocable at the introspection endpoint and you've wired the RS to call it. The [.NET client library](../integrate/resource-server) supports both — `AddModgudClient` for JWT, `AddModgudReferenceTokenClient` for reference tokens. +A resource server built with ASP.NET Core's JWT bearer handler expects a **JWT** — that's the right pick for the common case. Use **Reference** only when you specifically want every token resolvable/revocable at the introspection endpoint and you've wired the RS to call it. The [.NET resource-server library](../integrate/resource-server) uses one `AddModgudResourceServer` method; its `TokenMode` accepts JWTs, reference tokens, or both. ### Require Pushed Authorization Requests diff --git a/docs/concepts/apps-and-resource-access.md b/docs/concepts/apps-and-resource-access.md index 124a1190..efa1c901 100644 --- a/docs/concepts/apps-and-resource-access.md +++ b/docs/concepts/apps-and-resource-access.md @@ -165,10 +165,10 @@ Each resource server reads its own block. The Billing-API sees magnified — they each have it side-by-side, but consume just their own. -The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` -takes the matching audience block and flattens its roles onto -`ClaimTypes.Role`, so `[Authorize(Roles="Editor")]` works out of the -box without per-endpoint plumbing. +The `Modgud.AspNetCore.ResourceServer` authentication handlers take the +matching audience block and project its roles onto `ClaimTypes.Role`, so +`[Authorize(Roles="Editor")]` works out of the box without global claims +state or per-endpoint plumbing. ### What gets emitted is opt-in by scope diff --git a/docs/concepts/permissions.md b/docs/concepts/permissions.md index a68b00a9..9b258d93 100644 --- a/docs/concepts/permissions.md +++ b/docs/concepts/permissions.md @@ -1,9 +1,9 @@ # Permissions & gating Modgud uses **granular per-resource gating**: every endpoint and every -sidebar item checks a single permission string, and the same evaluator -runs IdP-side (Authorization slice) and resource-server-side -(Modgud.Client.AspNetCore). +sidebar item checks a single permission string. The IdP evaluates and +pre-expands grants; resource servers perform exact claim checks through +`Modgud.AspNetCore.ResourceServer`. ## Permission format @@ -151,10 +151,10 @@ What's in the block: (`roles`, `permissions`) — request `scope=permissions` to see the permissions block; without it you get just the roles list. -The `Modgud.Client.AspNetCore` helper lib's `IClaimsTransformation` -flattens the matching audience block onto the principal so standard -ASP.NET Core `[Authorize(Roles="…")]` and `RequiresPermission(…)` work -out of the box. +The `Modgud.AspNetCore.ResourceServer` authentication handlers project +the matching audience block onto the principal so standard ASP.NET Core +`[Authorize(Roles="…")]` and `RequireModgudPermission(…)` work out of +the box. ## Backend gating: `RequiresPermission` diff --git a/docs/contribute/local-ci.md b/docs/contribute/local-ci.md index c47269af..32f11f2b 100644 --- a/docs/contribute/local-ci.md +++ b/docs/contribute/local-ci.md @@ -119,7 +119,7 @@ What runs in dry-run: - `validate-version` — exercises the version-format check - `test-backend` — full unit + integration suite -- `pack-nuget` — packs the client-library nupkg +- `pack-nuget` — packs the resource-server nupkg - `build-docker` — builds the image, *doesn't push to GHCR* - `build-docs` — full VitePress build - `release-gate` — confirms all builds succeeded diff --git a/docs/contribute/testing/automated-tests.md b/docs/contribute/testing/automated-tests.md index d71217c9..2007636f 100644 --- a/docs/contribute/testing/automated-tests.md +++ b/docs/contribute/testing/automated-tests.md @@ -65,11 +65,13 @@ dotnet test | OAuthAdminMapping (extracted) | `Application/OAuthAdminMappingTests.cs` | 70+ | `BuildClientPermissions`, grant-type round-trip, `BuildClient*` defaults + property survival, `MapClient`/`MapScope`, `MapApiState` (id-stringification, defensive list copies), `MergeClientSettings`/`MergeClientProperties` partial-PATCH semantics (omit-preserve / value-overwrite / list-replace / no-mutation), BCrypt hash+verify round-trip and malformed-hash safety | | OAuth `*StateProjection` (3) + LoginProvider | `Infrastructure/Persistence/Marten/Projections/OAuth/*Tests.cs` + `LoginProviders/...Tests.cs` | 54 | Create + every Apply + replay (incl. AccessTokenType case-sensitive parse bug pinning, AppIds n:m projection, AppId set/null/created-default for Scope + Api) | -### ClaimsTransformation library +### Resource-server library | Area | File(s) | Tests | What's pinned | |---|---|---:|---| -| `ModgudClaimsTransformation` | `Client/AspNetCore/ModgudClaimsTransformationTests.cs` | 12 | per-app role flattening from `resource_access[].roles` to `ClaimTypes.Role`, cross-app isolation, malformed JSON tolerance, idempotence, anonymous short-circuit, `AppSlug` configuration validation | +| Scheme-local claims projection | `ResourceServer/ModgudClaimsProjectorTests.cs`, `ResourceServerRegistrationTests.cs` | — | audience isolation, role/permission projection, malformed JSON tolerance, idempotence, simultaneous JWT + introspection registration, startup validation, and absence of global claims transformation | +| Permission metadata | `ResourceServer/ModgudPermissionExtensionsTests.cs` | — | exact permission policy on endpoints and route groups | +| Reference-token introspection | `ResourceServer/IntrospectionHandlerTests.cs` | — | active/audience checks, claim construction, malformed response rejection, and scheme-local projection | ### ExternalAuth (OIDC IdP federation) diff --git a/docs/getting-started/features.md b/docs/getting-started/features.md index 22f54a1a..f72b9649 100644 --- a/docs/getting-started/features.md +++ b/docs/getting-started/features.md @@ -44,7 +44,7 @@ A point-by-point list of what Modgud delivers out of the box. ### Permission distribution to resource servers - **Own `resource_access` claim** (shaped like Keycloak's nested format for familiarity) emitted in `/connect/userinfo`, keyed by app slug, per-Audience - **Bypass-pre-expanded + per-RS narrowed** — consumers do straight exact-match without porting the evaluator -- **`Modgud.Client.AspNetCore`** library ships an `IClaimsTransformation` that flattens `resource_access[].roles` into `ClaimTypes.Role` so `[Authorize(Roles="...")]` works on resource servers without per-endpoint code +- **`Modgud.AspNetCore.ResourceServer`** supports local JWT validation and reference-token introspection; each authentication scheme projects its own audience block into native role and permission claims ### ABAC @@ -143,13 +143,13 @@ Modgud is a pure RBAC + grouping IAM. Row-level access policies (ABAC) live in t ## Developer integration ### Resource server libraries -- **`Modgud.Client.AspNetCore`** — drop-in `IClaimsTransformation` that flattens the per-Audience `resource_access` block onto the principal -- Standard `JwtBearerHandler` for token validation; nothing custom required on the framework side +- **`Modgud.AspNetCore.ResourceServer`** — explicit JWT and introspection handlers that validate tokens and project the configured audience block onto the principal +- JWT validation is local; reference-token validation uses RFC 7662 introspection for immediate revocation ### UserInfo as the permission delivery channel - `/connect/userinfo` emits `resource_access` keyed by app slug, per Audience - Bypass-pre-expanded server-side + narrowed to each RS's declared `OAuthApi.PermissionIds` subset -- Delivered via the standard OIDC UserInfo endpoint and standard JWT claims — any OIDC-aware consumer can parse it. `Modgud.Client.AspNetCore` adds the audience selection and claims projection for ASP.NET Core on top; it's not a custom protocol +- Delivered via standard JWT claims, UserInfo, and token-introspection responses — any OIDC-aware consumer can parse it. `Modgud.AspNetCore.ResourceServer` adds audience selection and scheme-local claims projection for ASP.NET Core; it's not a custom protocol ## Standards diff --git a/docs/index.md b/docs/index.md index e6023e42..b5b92745 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,10 +26,10 @@ features: details: Apps are first-class. Permissions are two-segment `:` strings (e.g. `todo:write`) scoped to an app via the role→App relationship, groups carry an activation list (BoundTo), roles bind to one app, and the resolver answers per-app permission queries in-memory. - icon: '' title: Per-app resource_access claim - details: Tokens carry resource_access keyed by app slug, shaped like Keycloak's nested claim for familiarity. A drop-in IClaimsTransformation library flattens the right block into ClaimTypes.Role so [Authorize(Roles="...")] works without per-endpoint plumbing. + details: Tokens carry resource_access keyed by app slug, shaped like Keycloak's nested claim for familiarity. The ASP.NET Core resource-server handlers project the selected audience block into native role and permission claims. - icon: '' title: Permissions on UserInfo - details: '/connect/userinfo emits per-Audience resource_access blocks with bypass-pre-expansion and per-RS subset narrowing, delivered via the standard UserInfo endpoint and JWT claims — any OIDC consumer can parse it. Modgud.Client.AspNetCore adds the ASP.NET Core audience selection and claims projection on top.' + details: 'Per-Audience resource_access blocks are bypass-pre-expanded and narrowed to each resource server. They travel in JWT, UserInfo, and introspection responses; Modgud.AspNetCore.ResourceServer adds scheme-local audience selection and claims projection.' - icon: '' title: Full 2FA spectrum + WebAuthn details: TOTP, email-OTP, FIDO2/Passkey, magic-link. 2FA enforcement middleware with grace period and per-user override. diff --git a/docs/integrate/index.md b/docs/integrate/index.md index 58c2519a..efad142d 100644 --- a/docs/integrate/index.md +++ b/docs/integrate/index.md @@ -13,7 +13,7 @@ protocol-specific pages. - [Resource server (.NET)](./resource-server) — the most common Cocoar scenario: protect an ASP.NET Core API with Modgud-issued - tokens via the `Modgud.Client.AspNetCore` NuGet package. + tokens via the `Modgud.AspNetCore.ResourceServer` NuGet package. - [SaaS app walkthrough](./saas-walkthrough) — full user-facing-app integration: client registration, login redirect, resource_access claims, role-based gating. diff --git a/docs/integrate/mcp-server.md b/docs/integrate/mcp-server.md index b8ede7dc..711e0720 100644 --- a/docs/integrate/mcp-server.md +++ b/docs/integrate/mcp-server.md @@ -123,7 +123,7 @@ After consent the agent holds an access token with `aud` narrowed to exactly `ht **Validate it on the MCP server.** Two options, same as any resource server: -- **JWT + JWKS (local):** validate signature against `https://auth.example.com/.well-known/jwks`, check `iss` equals the realm root and `aud` equals your MCP URL. CIMD clients are issued JWT access tokens, so this is the default MCP path. For an ASP.NET Core MCP server the wiring is identical to [Integrating a resource server](./resource-server) — same `AddJwtBearer` + `AddModgudClient`, with `Audience = "https://mcp.acme.example"`. +- **JWT + JWKS (local):** validate signature against `https://auth.example.com/.well-known/jwks`, check `iss` equals the realm root and `aud` equals your MCP URL. CIMD clients are issued JWT access tokens, so this is the default MCP path. For an ASP.NET Core MCP server the wiring is identical to [Integrating a resource server](./resource-server) — use `AddModgudResourceServer` with `Audience = "https://mcp.acme.example"`; `OnlyJwt` is the default mode. - **Introspection (server-side):** if your OAuth client issues reference tokens, `POST /connect/introspect` returns `active` plus the claims. Slower per call, but revocation is instant (see step 7). Decode the token (or introspect it) and confirm `aud` is your MCP URL alone and the `permissions` array inside `resource_access[acme]` holds what you expect. diff --git a/docs/integrate/resource-server.md b/docs/integrate/resource-server.md index d207b7ad..7ba766c4 100644 --- a/docs/integrate/resource-server.md +++ b/docs/integrate/resource-server.md @@ -1,96 +1,82 @@ # Integrating a Resource Server -This guide walks through wiring an ASP.NET Core resource server to Modgud so it can: +`Modgud.AspNetCore.ResourceServer` protects ASP.NET Core APIs with access +tokens issued by Modgud. One registration method configures one public +authentication scheme for self-contained JWTs, opaque reference tokens, or +both. -1. Validate access tokens that Modgud issued (JWT signature + issuer + audience, against the realm's JWKS) -2. Pick up role claims so `[Authorize(Roles = "…")]` works -3. Read fine-grained permission strings from the per-audience `resource_access` block so it can gate on `:` checks +All modes: -The reference scenario is a fictional `acme` app with a `todo` resource — replace the slugs with yours throughout. +- validate issuer and audience; +- select the configured `resource_access[]` block; +- project roles to `ClaimTypes.Role`; +- project permissions to `ModgudClaimTypes.Permission`; +- support `RequireModgudPermission(":")`. -A runnable end-to-end sample lives in the Modgud source tree at `src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs` (the protected API) and `src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs` (a cookie-based BFF that obtains and forwards the token). The ResourceApi sample validates JWTs by default and switches to reference-token introspection with `TESTAPPS:TOKENMODE=reference` (+ `TESTAPPS:INTROSPECTIONSECRET`). The code below mirrors those samples; when in doubt, read them. Both library paths they use — JWT-bearer and reference-token introspection — are covered end-to-end by the integration-test rig (opaque/JWT token in → validation → `resource_access` → `RequiresModgudPermission` gate). +A runnable sample lives at +`src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs`. It uses JWT by +default. Set `TESTAPPS:TOKENMODE=reference` or `both` and provide +`TESTAPPS:INTROSPECTIONSECRET=` for the other modes. -## Prerequisites +## Admin prerequisites -Before wiring code, finish the admin setup in Modgud. The full admin walkthrough lives at [SaaS App Integration Walkthrough](./saas-walkthrough); the essentials are: +For the example audience `acme`: -1. Create the app `acme` with its permission catalog (`:` entries such as `todo:read`, `todo:write`) -2. Create an OAuth API (resource server) named `acme` under **OAuth → APIs**, link it to the `acme` app, and pick the catalog subset its `PermissionIds` cover. Linking an API to an app creates an implicit scope whose `Resources` include `acme` — that is what stamps `aud=acme` onto tokens requested with that scope. -3. Create an OAuth client (e.g. `acme-web`) for the app's frontend. Set its **Access Token Type** to **JWT (self-contained)** — see the prerequisite below. -4. Set up at least one role + group with `BoundTo: ["acme"]` and assign your test user. +1. Create the app `acme` and its permission catalog, such as `todo:read` and + `todo:write`. +2. Create an OAuth API named `acme`, link it to the app, and select the + permissions this API may receive. +3. Allow the OAuth client to request the API's implicit scope plus `roles` and + `permissions`. +4. Assign roles or permissions to the user through groups bound to the app. -### Two token modes — pick one +The authorization request must include the relevant scopes: -Modgud issues access tokens in one of two formats, and `Modgud.Client.AspNetCore` supports both. Choose per resource server: +- the API scope adds `aud=acme`; +- `roles` adds `resource_access[acme].roles`; +- `permissions` adds `resource_access[acme].permissions`. -- **JWT (self-contained)** — a signed bearer JWT carrying `aud`, `scope`, and the standard claims, validated locally against the realm's JWKS with no per-request IdP call. **This guide uses JWT.** It requires setting the OAuth client's **Access Token Type** to **JWT (self-contained)** (the field defaults to `Reference` in the client editor). -- **Reference (opaque)** — Modgud's **default** format: an opaque handle with no embedded claims, validated by calling `/connect/introspect` (RFC 7662). No client reconfiguration needed. Wire it with `AddModgudReferenceTokenClient` instead of `AddJwtBearer` + `AddModgudClient` — see [Reference-token mode](#reference-token-mode-opaque-tokens) below. +## Realm authority -The endpoint gates (`[Authorize(Roles=…)]`, `RequiresModgudPermission`) and the projected role/permission claims are identical in both modes — only the authentication registration differs. +Modgud resolves realms by host name, not by a URL path. `Authority` must be the +realm's host root: -### Prerequisite: request the right scopes +- correct: `https://auth.example.com` +- wrong: `https://auth.example.com/system` -The token only carries what was requested: +Discovery, JWKS, token, UserInfo, and introspection endpoints all live below +that host root. -- `aud=acme` is present only when a requested scope carries `Resources=[acme]` — i.e. the implicit scope created when you linked the `acme` API to the `acme` app (step 2). Without it the token has no `acme` audience and `AddJwtBearer` rejects it with an audience mismatch. -- The `permissions` array inside `resource_access[acme]` appears only when the client requested the `permissions` scope. -- The `roles` array inside `resource_access[acme]` appears only when the client requested the `roles` scope. - -Both `roles` and `permissions` are standard scopes seeded into every realm. Add them (plus the API's implicit scope) to the client's allowed scopes and to the authorization request. The [SaaS App Integration Walkthrough](./saas-walkthrough) covers this end to end. - -## Host-based realm routing — get the `Authority` right - -Modgud resolves realms by the **Host header only**. The issuer carries **no** realm path segment. Each realm answers on its own host (or hostname), and the OIDC discovery document, JWKS, token issuer, and UserInfo endpoint all live at the host root. - -That means `Authority` MUST be the realm's **host root**: - -- Correct: `https://auth.example.com` -- Wrong: `https://auth.example.com/system` or any `https://auth.example.com/` path - -A path-suffixed authority makes `AddJwtBearer` fetch discovery from `https://auth.example.com/system/.well-known/openid-configuration` (404) and validate the issuer against `https://auth.example.com/system` — both fail. Use the bare host root and let the Host header select the realm. - -## ASP.NET Core integration - -### 1. Add the package +## Install ```bash -dotnet add package Modgud.Client.AspNetCore -dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer +dotnet add package Modgud.AspNetCore.ResourceServer ``` -### 2. Configure authentication and the Modgud client +## JWT mode -`AddJwtBearer` validates the JWT. `AddModgudClient` adds the two pieces vanilla `AddJwtBearer` lacks: a post-configure on the JwtBearer scheme that makes sure the principal ends up with a `resource_access` claim — preferring the one already embedded in the token and calling `/connect/userinfo` only as a fallback when the token carries none (you do **not** set `GetClaimsFromUserInfoEndpoint`, that property is for `AddOpenIdConnect`) — and a claims transformation that flattens the per-audience block into native role/permission claims, plus the `RequiresModgudPermission` endpoint filter. +JWT is the recommended quickstart: validation is local and does not add an IdP +round-trip to each API request. Configure the issuing OAuth client to use +**JWT (self-contained)** access tokens. `OnlyJwt` is the default token mode. ```csharp -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Modgud.Client.AspNetCore; - -JwtSecurityTokenHandler.DefaultMapInboundClaims = false; +using Modgud.AspNetCore.ResourceServer; var builder = WebApplication.CreateBuilder(args); -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = "https://auth.example.com"; // realm host root — NO realm path segment - options.Audience = "acme"; // matches the OAuthApi name registered in Modgud - options.MapInboundClaims = false; - options.TokenValidationParameters.NameClaimType = "name"; - options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; - }); - -builder.Services.AddModgudClient(o => +builder.Services.AddModgudResourceServer(options => { - o.Authority = "https://auth.example.com"; // same as JwtBearer Authority - o.Audience = "acme"; // same as JwtBearer Audience + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.TokenValidationParameters.NameClaimType = "name"; + jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + }; }); -builder.Services.AddAuthorization(); - var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); @@ -99,131 +85,170 @@ app.MapGet("/me", (ClaimsPrincipal user) => new { sub = user.FindFirstValue("sub"), name = user.Identity?.Name, - roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value), - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType).Select(c => c.Value), + roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value), + permissions = user.FindAll(ModgudClaimTypes.Permission) + .Select(claim => claim.Value), }).RequireAuthorization(); app.MapGet("/todos", () => Results.Ok(new[] { "buy milk" })) - .RequireAuthorization() - .RequiresModgudPermission("todo:read"); + .RequireModgudPermission("todo:read"); app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); + .RequireModgudPermission("todo:write"); app.Run(); ``` -`ModgudOptions` has exactly two required properties — `Authority` and `Audience` — plus an optional `JwtBearerScheme` (default `"Bearer"`) if you registered JwtBearer under a custom scheme name. Both `Authority` and `Audience` must match the values you passed to `AddJwtBearer`. - -### DPoP-bound tokens are enforced automatically +The package validates the token and projects only its embedded +`resource_access` claim. There is no global `IClaimsTransformation` and no +UserInfo fallback. A JWT without the required authorization data may still +authenticate, but role and permission gates remain fail-closed. -If a client obtains a [DPoP](../reference/oauth-api#dpop-sender-constrained-tokens)-bound access token (one carrying a `cnf.jkt` confirmation claim), `AddModgudClient` enforces the binding for you — no extra configuration. It accepts the token under the `DPoP` auth scheme (lifting it into JwtBearer, which only reads `Bearer` on its own), then requires a valid DPoP proof whose key matches `cnf.jkt` and whose `ath` hashes the presented token. A bound token replayed as a plain `Bearer`, or with a proof for the wrong key, is rejected. Unbound tokens are unaffected and keep working as bearer tokens. The same enforcement applies on the [reference-token path](#reference-token-mode-opaque-tokens) — the `cnf.jkt` is read from the introspection response instead of the JWT. +JWT authorization data reflects token issuance time. Grant changes become +visible when a new token is issued; revocation is bounded by the access-token +lifetime. -Behind a reverse proxy, wire up `UseForwardedHeaders` so the request's scheme + host match what the client signed into the proof's `htu`, or every proof fails the URL check. +## Reference-token mode -## Where permissions come from +Reference tokens are useful when revocation must take effect immediately. The +OAuth client may remain on Modgud's **Reference** access-token type. -`resource_access` is baked directly into the access token at issuance — for JWT clients (the type this guide sets up) it's a claim inside the token itself. `Modgud.Client.AspNetCore` prefers that embedded claim: if the JwtBearer-validated principal already carries `resource_access`, the library reads it as-is and never calls the IdP. It falls back to fetching `/connect/userinfo` only when the token carries no such claim — practically, that's tokens from setups predating this behavior, or resource servers validating opaque reference tokens by some means other than local JWT parsing (this guide's JWKS-based `AddJwtBearer` setup always sees the embedded claim, so the fallback path is dead code in practice for it). - -This is a pure performance win, not a freshness trade-off: `/connect/userinfo` has always echoed the exact same `resource_access` block already baked into the token, never a wider or narrower one, so preferring the token claim changes nothing about which permissions your resource server sees — it only removes a redundant HTTP round-trip for tokens that already carry the claim. - -| Source | Freshness | IdP dependency per request | -|---|---|---| -| Embedded in token (JWT `resource_access` claim, preferred) | As of token issuance — a grant or revocation takes effect once a new token is minted; propagation is bounded by the access token's lifetime | None | -| `/connect/userinfo` fallback (only when the token carries no `resource_access` claim) | Same as above — UserInfo echoes the token's baked block, it does not recompute a live view | One UserInfo call per request, only for tokens lacking the claim | - -## Performance and availability +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` -For tokens that already carry an embedded `resource_access` claim — every JWT-client token, per the prerequisite above — `AddModgudClient` makes **no IdP call at all**: the claims transformation runs purely against data already on the token, so there is no per-request round-trip and nothing to degrade. +Every authenticated request calls `/connect/introspect`. The single response +validates the token and carries the same audience-specific `resource_access` +block as a JWT. Responses are not cached. An inactive token, a failed response, +invalid JSON, or an unreachable IdP rejects authentication. -The `/connect/userinfo` fallback runs only for tokens without an embedded claim, and the following applies to that path alone. It **degrades without failing the authentication handler** — a `/connect/userinfo` failure never rejects the request outright. But authorization on that path stays **fail-closed**: if the IdP is unreachable or returns a non-2xx, no `resource_access` claim is added, so any endpoint gated with `RequiresModgudPermission` returns `403` (the principal simply carries no permissions) rather than the API 500ing during an IdP outage. +### Register the introspection client -One caveat, still true either way: fail-closed behavior only protects endpoints actually gated on a permission. An endpoint secured with a bare `.RequireAuthorization()` and no `RequiresModgudPermission` call has nothing checking `resource_access` in the first place, so it stays reachable straight through a fallback-path outage. If that matters for a given endpoint, gate it on a permission too. +Create a confidential OAuth client for the resource server: -Because a JWT-client token already carries the claim it needs, an IdP outage no longer 403s requests bearing a still-valid token — those requests never touch the IdP for authorization data in the first place. The fail-closed behavior above only bites setups still on the `/connect/userinfo` fallback path. +1. Set its client ID to the resource-server audience, for example `acme`. +2. Generate a client secret. +3. Put that secret in protected application configuration. -## Reading roles and permissions +`IntrospectionClientId` defaults to `Audience`. Modgud returns an active +introspection result only to the token's presenter or one of its audiences, +which is why the normal resource-server client ID equals the audience. -The claims transformation projects the per-audience block onto flat claims: +## Accept both formats -- Roles land on `ClaimTypes.Role`, so `[Authorize(Roles = "Editor")]`, `RequireRole(...)`, and `user.FindAll(ClaimTypes.Role)` all work. -- Permissions land on the claim type `ModgudClaimsTransformation.PermissionClaimType` (value `"permission"`). Read them with `user.FindAll(ModgudClaimsTransformation.PermissionClaimType)`. +One API can accept both token formats through the same registration and public +authentication scheme: ```csharp -// Coarse role gate. -app.MapGet("/admin/reports", () => Results.Ok()) - .RequireAuthorization(p => p.RequireRole("Editor")); - -// Granular permission gate — the canonical way. -app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); ``` -`RequiresModgudPermission(":")` is an extension on both `RouteHandlerBuilder` (per-endpoint) and `RouteGroupBuilder` (whole group). It does a straight exact-match against the principal's `"permission"` claims: `401` when anonymous, `403` when authenticated but lacking the permission. The permission string is bare 2-segment (`todo:write`) — the app context is implicit from the audience you configured. +The package routes signed Modgud JWTs, which consist of exactly three +dot-separated parts, to the JWT validator. Dotless opaque tokens go to +introspection. Routing is not validation: the selected handler still validates +the token completely and fails closed. It never retries a failed JWT through +introspection. -::: tip Roles and permissions compose -The same user can be `Roles = "Editor"` **and** hold `todo:write`. Pick role gates for coarse buckets (`Admin` / `Editor` / `Viewer`) and `RequiresModgudPermission` for per-action checks. Both flavours read from the same `resource_access` block — the token's own embedded copy by default — so there is no separate server-to-server call to wire up. -::: +Only one `AddModgudResourceServer(...)` call is allowed for a service +collection. This prevents accidentally registering conflicting Modgud modes. +An application can still intentionally add unrelated ASP.NET Core +authentication schemes alongside Modgud. -::: warning Groups are not emitted -The IdP never emits a `groups` block in `resource_access` (hub boundary). Group membership is resolved IdP-side and expanded into roles/permissions before emission. Gate on roles or permissions only — there is no group claim to read. -::: +## Roles and permissions -## What's in the permissions array +The IdP emits a Keycloak-shaped block: -The IdP does two transformations before emitting the per-audience block, so your resource server never needs an evaluator: +```json +"resource_access": { + "acme": { + "roles": ["Acme Editor"], + "permissions": ["todo:read", "todo:write"] + } +} +``` -- **Bypass pre-expansion**: bypass tiers are resolved to concrete catalog strings before emission. `realm:admin` expands to every concrete catalog entry of every reachable app; an `:admin` grant expands to every entry in that app's catalog; a `:admin` grant expands to every `:` in the app's catalog. Your check is always exact-match. -- **Per-RS subset narrowing**: each audience block is narrowed to the calling OAuth API's declared `PermissionIds`. A resource server within a multi-RS app sees only its own permissions, never a sibling's. +Use standard ASP.NET role policies for coarse access: -## Reference-token mode (opaque tokens) +```csharp +app.MapGet("/admin", () => Results.Ok()) + .RequireAuthorization(policy => policy.RequireRole("Acme Editor")); +``` -If you'd rather leave the OAuth client on Modgud's default **Reference** token type, validate via introspection instead of JWKS. Everything downstream — the claims transformation, `RequiresModgudPermission`, role gates — is unchanged; only the authentication registration differs: +Use Modgud permission metadata for action-level access: ```csharp -using Modgud.Client.AspNetCore; - -builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "https://auth.example.com"; // realm host root - o.Audience = "acme"; // the OAuthApi name == introspection client_id - o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - }); +app.MapPost("/todos", () => Results.Ok()) + .RequireModgudPermission("todo:write"); ``` -Each request calls `/connect/introspect`, and the introspection response carries the same per-audience `resource_access` block a JWT would — so a single call both validates the token and yields the permissions. Validation is **fail-closed** (an inactive token, a non-2xx, or an IdP outage rejects the request) and there is **no cache**, so a revoked reference token stops working immediately. +The extension works on route handlers and route groups. It requires an +authenticated user and the exact permission claim, returning `401` when +anonymous and `403` when authenticated without the permission. + +The IdP expands `realm:admin` and `:admin` bypass grants into +concrete catalog permissions before emission. It also narrows each audience to +that OAuth API's declared permission subset. Resource servers therefore do +exact matching and do not need `PermissionEvaluator`. + +Groups are not emitted across the IdP boundary. Group membership is resolved +to roles and permissions before token issuance. + +## DPoP -### Setup: register the introspection client +Both validation paths enforce DPoP binding automatically when a token contains +`cnf.jkt`. A bound token must use the `DPoP` authorization scheme and include a +valid proof whose key and access-token hash match. Replaying it as plain +`Bearer` is rejected. Unbound bearer tokens continue to work normally. -The IdP only reveals a token — its `active` status and its `resource_access` — to a caller that is one of the token's audiences or its presenter. So the resource server introspects with a confidential OAuth client whose **`client_id` equals its audience** (the RS's `OAuthApi` name, which RFC 8707 already puts in the token's `aud`): +Behind a reverse proxy, configure forwarded headers so the externally visible +scheme and host match the proof's signed `htu`. -1. In Modgud admin, create a **confidential OAuth Client** whose **Client ID** is exactly your audience (e.g. `acme`, or `https://mcp.acme.example` for the MCP case). Give it a secret; it needs no redirect URIs or grant types beyond existing to authenticate. -2. Pass that secret as `IntrospectionClientSecret`. `IntrospectionClientId` defaults to `Audience`, so you don't set it unless the introspection client is registered under a different (still audience-matching) id. +## Options and startup validation -Credentials go in the request body (`client_secret_post`), which also covers a URL-shaped audience id — HTTP Basic would break on the scheme colon. +| Option | Required | Description | +| --- | --- | --- | +| `Authority` | Always | Realm host root; HTTPS is required by default. | +| `Audience` | Always | Token audience and `resource_access` key. | +| `TokenMode` | No | `OnlyJwt` (default), `OnlyReferenceToken`, or `Both`. | +| `IntrospectionClientId` | No | Defaults to `Audience` in reference-capable modes. | +| `IntrospectionClientSecret` | Reference/Both | Confidential introspection secret. | +| `RequireHttpsMetadata` | No | Set `false` only for local development. | +| `ConfigureJwtBearer` | No | Advanced JWT configuration in JWT-capable modes. | -::: warning A separate introspection identity won't work -A confidential client whose `client_id` is *not* one of the token's audiences gets `active: false` from `/connect/introspect` — the IdP reveals nothing to a stranger. The `client_id == audience` registration above is what makes introspection return an active status and the `resource_access` block. -::: +C# `required` properties cannot express a requirement conditional on +`TokenMode`. The registration therefore validates the complete combination +immediately and throws `OptionsValidationException` for invalid or irrelevant +options. ## Common pitfalls -- **`Authority` has a realm path segment** — e.g. `https://auth.example.com/system`. Discovery fetch 404s and issuer validation fails. Realms route by Host header; `Authority` is the bare host root. -- **Client issues Reference (opaque) tokens** — `AddJwtBearer` cannot validate them. Set the OAuth client's **Access Token Type** to **JWT (self-contained)**. -- **Token's `aud` doesn't match `Audience`** — JWT validation rejects with an audience mismatch. `aud=acme` only appears when a requested scope carries `Resources=[acme]` (the implicit scope from linking the API to the app). Align the API name, the requested scope, and `options.Audience`. -- **`Authority` / `Audience` differ between `AddJwtBearer` and `AddModgudClient`** — UserInfo is fetched from the wrong host or the transformation reads the wrong `resource_access[…]` key, so roles/permissions silently go missing. Keep both pairs identical. -- **`permissions` scope not requested** — `resource_access[acme]` has no `permissions` array, so every `RequiresModgudPermission` gate denies. Add the `permissions` scope to the client's allowed scopes and to the authorization request (same for `roles`). -- **Resource server not linked to an app** — without a linked app there is no `PermissionIds` subset, so the audience block is empty. Open the OAuth API in Modgud admin and assign the app. +- A realm path is appended to `Authority`; use the bare realm host root. +- The configured mode does not accept the OAuth client's access-token type. +- The requested API scope did not add the configured audience. +- The authorization request omitted `roles` or `permissions`. +- The OAuth API is not linked to an app or has no selected permissions. +- The introspection client's ID is not the token audience. +- `UseAuthentication()` or `UseAuthorization()` is missing or ordered after + endpoint execution. ## Reference -- Working sample: `src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs` (+ BFF at `src/dotnet/TestApps/Modgud.TestApps.Bff/Program.cs`). Runs in JWT mode by default; set `TESTAPPS:TOKENMODE=reference` (+ `TESTAPPS:INTROSPECTIONSECRET`) for the reference-token introspection path. -- Admin walkthrough: [SaaS App Integration Walkthrough](./saas-walkthrough) -- Concept overview: [Apps and resource_access](../concepts/apps-and-resource-access.md) -- Permissions reference: [Permissions & gating](../concepts/permissions.md) -- OAuth endpoints: [reference/oauth-api](../reference/oauth-api.md) -- Library source: `src/dotnet/Modgud.Client.AspNetCore/` +- [SaaS App Integration Walkthrough](./saas-walkthrough) +- [Apps and resource_access](../concepts/apps-and-resource-access.md) +- [Permissions and gating](../concepts/permissions.md) +- [OAuth API](../reference/oauth-api.md) +- Source: `src/dotnet/Modgud.AspNetCore.ResourceServer/` diff --git a/docs/integrate/saas-walkthrough.md b/docs/integrate/saas-walkthrough.md index 47a01140..5871515d 100644 --- a/docs/integrate/saas-walkthrough.md +++ b/docs/integrate/saas-walkthrough.md @@ -194,45 +194,31 @@ A complete, runnable version of everything below ships in the repo at `src/dotne ### Packages ```bash -dotnet add package Modgud.Client.AspNetCore -dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer +dotnet add package Modgud.AspNetCore.ResourceServer ``` ### `Program.cs` ```csharp using System.Security.Claims; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Modgud.Client.AspNetCore; - -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - // Authority is the realm's HOST ROOT — realms resolve by Host - // header, so the issuer has NO realm path. Never append "/system" - // or any "/" segment: a path-suffixed Authority makes the - // discovery fetch 404 and fails issuer validation. - options.Authority = "https://auth.example.com"; - options.Audience = "acme"; // matches the OAuthApi name / aud claim - }); - -// AddModgudClient (hooks JwtBearerEvents.OnTokenValidated) makes sure the -// principal ends up with resource_access["acme"] — preferring the claim -// already embedded in the JWT (the normal case for a JWT-typed client -// like this one) and calling /connect/userinfo only as a fallback for -// tokens that carry none — then registers the ClaimsTransformation that -// flattens the block onto the principal: +using Modgud.AspNetCore.ResourceServer; + +builder.Services.AddModgudResourceServer(options => +{ + // Authority is the realm's HOST ROOT — realms resolve by Host + // header, so the issuer has NO realm path. Never append "/system" + // or any "/" segment: a path-suffixed Authority makes the + // discovery fetch 404 and fails issuer validation. + options.Authority = "https://auth.example.com"; + options.Audience = "acme"; // matches the OAuthApi name / aud claim + // TokenMode defaults to OnlyJwt. +}); + +// The scheme projects the JWT's embedded audience block directly: // - resource_access["acme"].roles → ClaimTypes.Role // - resource_access["acme"].permissions → "permission" claims // The IdP pre-expands bypass tiers (realm:admin, :admin) before // emission, so the RS only ever does exact-match — no evaluator on this side. -// Do NOT set GetClaimsFromUserInfoEndpoint on AddJwtBearer; AddModgudClient -// owns claim-sourcing (token first, UserInfo fallback). -builder.Services.AddModgudClient(o => -{ - o.Authority = "https://auth.example.com"; - o.Audience = "acme"; // must equal JwtBearerOptions.Audience above -}); builder.Services.AddAuthorization(); ``` @@ -250,23 +236,23 @@ transformation surfaces `resource_access["acme"].roles` as ### Granular permission check -Gate endpoints with `.RequiresModgudPermission(...)` — the filter reads -the flattened `permission` claims and does a straight exact-match: +Gate endpoints with `.RequireModgudPermission(...)` — the authorization +policy reads the flattened `permission` claims and does a straight +exact-match: ```csharp app.MapPost("/todos", () => Results.Ok()) - .RequireAuthorization() - .RequiresModgudPermission("todo:write"); + .RequireModgudPermission("todo:write"); ``` If you need to read permissions imperatively, they live under -`ModgudClaimsTransformation.PermissionClaimType`: +`ModgudClaimTypes.Permission`: ```csharp app.MapGet("/whoami", (ClaimsPrincipal user) => Results.Ok(new { permissions = user - .FindAll(ModgudClaimsTransformation.PermissionClaimType) + .FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value), })).RequireAuthorization(); ``` @@ -286,11 +272,10 @@ common pitfalls) live in 7. The resulting access token already carries `sub`, `email`, `name`, and `resource_access.acme.roles = ["Acme Editor"]` plus `resource_access.acme.permissions = ["todo:read", "todo:write"]` — - `AddModgudClient` reads that straight off the validated JWT - (`/connect/userinfo` would show the same block, but the library only - calls it as a fallback for tokens that don't carry the claim) + `AddModgudResourceServer` reads that straight off the validated JWT + without a UserInfo round-trip 8. `[Authorize(Roles = "Acme Editor")]` lets you in, and - `.RequiresModgudPermission("todo:write")` passes — the resource + `.RequireModgudPermission("todo:write")` passes — the resource server validated the JWT against the realm's JWKS (because the client's Access Token Type is JWT) and matched the flattened `permission` claims @@ -338,7 +323,7 @@ Made it through? **Done. First SaaS app integrated.** request. - **`scope=permissions` not requested.** Without it, the `permissions` array in the `resource_access` block is omitted — your - `RequiresModgudPermission(…)` check sees nothing. Same for `roles` + `RequireModgudPermission(…)` check sees nothing. Same for `roles` and the role list. Add the scope to the client's allowed-scopes list and to every authorization request. - **Access Token Type left as Reference.** `AddJwtBearer` can only diff --git a/docs/operate/backend-architecture.md b/docs/operate/backend-architecture.md index 01b57634..6c6747bb 100644 --- a/docs/operate/backend-architecture.md +++ b/docs/operate/backend-architecture.md @@ -14,7 +14,7 @@ src/dotnet/ ├── Modgud.Application/ ← DTOs, service interfaces ├── Modgud.Infrastructure/ ← OpenIddict stores, tenancy, realm cache, Wolverine handlers ├── Modgud.Permissions.Abstractions/ ← Shared permission evaluator (realm:admin / :admin bypass tiers) -├── Modgud.Client.AspNetCore/ ← Published NuGet package: ASP.NET Core integration for resource servers +├── Modgud.AspNetCore.ResourceServer/ ← Published NuGet package: JWT and introspection integration for resource servers ├── Modgud.Provisioning.TestKit/ ← Published NuGet package: throwaway realms for integration tests ├── Modgud.Api/ ← Minimal API endpoints, middleware, setup, SignalR hub ├── Modgud.Api.Tests/ ← Integration tests (Testcontainers + PostgreSQL) diff --git a/docs/operate/supply-chain.md b/docs/operate/supply-chain.md index 144b5959..bcc85d49 100644 --- a/docs/operate/supply-chain.md +++ b/docs/operate/supply-chain.md @@ -13,7 +13,7 @@ Images and packages from v0.6.0 and earlier predate the signing pipeline — the | Trivy scan gate | The image had no known fixable CRITICAL/HIGH vulnerabilities at publish time — each architecture is scanned separately, and a finding blocks the whole release | Enforced in CI (`cd-release.yml`), not a downloadable artifact | | cosign signature (keyless) | The image digest was signed by the release workflow itself, via its short-lived OIDC identity — there is no long-lived signing key that could leak | GHCR, next to the image; log entry in the public Rekor transparency log | | Build-provenance attestation (image) | Which repository, workflow, commit, and run built the image | GitHub attestation store + GHCR | -| Build-provenance attestation (NuGet) | Same, for the `Modgud.Client.AspNetCore` package | GitHub attestation store | +| Build-provenance attestation (NuGet) | Same, for the `Modgud.AspNetCore.ResourceServer` package | GitHub attestation store | | SPDX SBOMs (per arch) | The full component inventory of the image, one file per platform | GitHub release assets (`modgud--linux-.spdx.json`) | | BuildKit inline SBOM + provenance | Machine-readable equivalents embedded in the image manifest | GHCR, part of the multi-arch manifest list | @@ -38,7 +38,7 @@ Both checks operate on the image digest, and every release tag (`:`, `: ## Verify the NuGet package ```bash -gh attestation verify Modgud.Client.AspNetCore..nupkg -R cocoar-dev/modgud +gh attestation verify Modgud.AspNetCore.ResourceServer..nupkg -R cocoar-dev/modgud ``` This proves the exact `.nupkg` you downloaded from nuget.org was produced by the release workflow in this repository, at the commit the attestation names. diff --git a/docs/reference/oauth-api.md b/docs/reference/oauth-api.md index ce2b59b6..9ccef1cf 100644 --- a/docs/reference/oauth-api.md +++ b/docs/reference/oauth-api.md @@ -211,7 +211,7 @@ Authorization: DPoP DPoP: ``` -The [.NET client library](../integrate/resource-server) enforces the binding on both token formats: a bound token presented as a plain `Bearer`, or with a proof whose key doesn't match `cnf.jkt`, is rejected. +The [.NET resource-server library](../integrate/resource-server) enforces the binding on both token formats: a bound token presented as a plain `Bearer`, or with a proof whose key doesn't match `cnf.jkt`, is rejected. ### 3. Refresh tokens are bound too diff --git a/docs/roadmap.md b/docs/roadmap.md index f4c2c7e8..e2a1013c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ in a changelog that ages between releases. - Per-Audience `resource_access` emission on `/connect/userinfo` with bypass pre-expansion and per-RS subset narrowing — drop-in for Keycloak-shaped client libraries; native via the - `Modgud.Client.AspNetCore` NuGet package + `Modgud.AspNetCore.ResourceServer` NuGet package **OAuth 2.0 / OpenID Connect (OpenIddict 7)** diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs index 5fa3d178..f64c3b67 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/DpopIssuanceTests.cs @@ -348,7 +348,7 @@ public async Task An_unbound_refresh_token_is_redeemable_without_a_proof() [Fact] public async Task Introspection_of_a_dpop_bound_reference_token_echoes_cnf_jkt() { - // The resource-server client library reads cnf.jkt out of the + // The resource-server package reads cnf.jkt out of the // introspection response to enforce the DPoP binding on opaque reference // tokens — this pins that the AS actually surfaces it. var (clientId, secret, redirectUri) = await NewClientAsync("dpop-ref", AccessTokenType.Reference); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index 4a20bafd..1a98c63c 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -15,7 +15,7 @@ using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Permissions.Abstractions; -using Modgud.Client.AspNetCore; +using Modgud.AspNetCore.ResourceServer; using Marten; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -657,7 +657,7 @@ public async Task Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Pres { // #132 step 1 — pin whether /connect/introspect echoes the per-audience // resource_access block, and to which callers. This decides the - // reference-token client-lib design: if a resource server can introspect + // reference-token resource-server design: if a resource server can introspect // and read the permission block in one call, the lib needs no separate // /connect/userinfo round-trip. Nothing in-repo pinned this before (the // #132 issue explicitly flags the gap). @@ -1067,16 +1067,63 @@ await Factory.CreateTestGroupAsync( boundTo: groupBoundTo.ToList()); } - // ── #139: end-to-end reference-token resource server (client library) ─────── + // ── Resource-server package end-to-end ────────────────────────────────────── + + [Fact] + public async Task Jwt_ResourceServer_Gates_On_Embedded_Permission() + { + var app = await CreateAppAsync("rs-jwtapp", "RS JWT App", + permissions: [("policy", "read"), ("policy", "admin")]); + const string audience = "https://rs-jwt.example.com"; + await CreateOAuthApiAsync(audience, app.Id); + const string scopeName = "rs-jwt-api"; + await CreateScopeAsync(scopeName, [audience], app.Id); + + var clientSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var clientId = "test-jwtrs-" + Guid.NewGuid().ToString("N"); + const string redirectUri = "http://localhost/test-callback"; + await CreateOAuthClientAsync( + clientId, clientSecret, redirectUri, [app.Id], + ["openid", "roles", "permissions", scopeName], AccessTokenType.Jwt); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Jwt", lastname: "RS", acronym: "jr", + email: "jr@test.com", password: "TestPass1234"); + await GrantAsync(user.Id, roleAppSlug: "rs-jwtapp", resourceType: "policy", + actions: ["read"], groupBoundTo: ["rs-jwtapp"]); + + var jwt = await DriveAuthCodeFlowAsync( + username: "jr", password: "TestPass1234", + clientId: clientId, clientSecret: clientSecret, + redirectUri: redirectUri, + scope: $"openid roles permissions {scopeName}", + resources: [audience]); + Assert.Contains('.', jwt); + + using var rsHost = await BuildJwtResourceServerAsync(audience); + var rs = rsHost.GetTestClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.Forbidden, + (await SendWithTokenAsync(rs, "/policy/admin", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.Unauthorized, + (await rs.GetAsync( + "/policy/read", + TestContext.Current.CancellationToken)).StatusCode); + } [Fact] public async Task ReferenceToken_ResourceServer_Gates_On_Introspected_Permission() { // #139 — the runnable reference-token sample's path, proven end-to-end - // through the client library: an opaque access token is validated by - // `AddModgudReferenceTokenClient` via /connect/introspect, the per-audience + // through the resource-server package: an opaque access token is validated by + // `AddModgudResourceServer` via /connect/introspect, the per-audience // resource_access block is projected onto the principal, and a - // `RequiresModgudPermission` gate does exact-match. The IdP side (which + // `RequireModgudPermission` policy does exact-match. The IdP side (which // callers get resource_access) is pinned separately by // Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Presenter_Client; // this pins the resource-server half. @@ -1115,11 +1162,6 @@ await GrantAsync(user.Id, roleAppSlug: "rs-refapp", resourceType: "policy", username: "rr", password: "TestPass1234", clientId: clientId, clientSecret: clientSecret, redirectUri: redirectUri, scope: $"openid roles permissions {scopeName}", resources: [audience]); - // Point the library's introspection HttpClient at the in-memory IdP (its - // Authority is http://localhost, so the introspection Host resolves to the - // same realm the fixtures were created in). - ModgudTokenIntrospection.SharedClient = Factory.CreateClient(); - using var rsHost = await BuildReferenceTokenResourceServerAsync(audience, introspectionSecret); var rs = rsHost.GetTestClient(); @@ -1141,6 +1183,84 @@ await GrantAsync(user.Id, roleAppSlug: "rs-refapp", resourceType: "policy", Assert.Equal(HttpStatusCode.Unauthorized, (await rs.GetAsync("/policy/read", TestContext.Current.CancellationToken)).StatusCode); } + [Fact] + public async Task Both_Mode_Accepts_Jwt_And_Reference_Token_On_The_Same_Endpoint() + { + var app = await CreateAppAsync("rs-bothapp", "RS Both App", + permissions: [("policy", "read")]); + const string audience = "https://rs-both.example.com"; + await CreateOAuthApiAsync(audience, app.Id); + const string scopeName = "rs-both-api"; + await CreateScopeAsync(scopeName, [audience], app.Id); + + var jwtSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var jwtClientId = "test-both-jwt-" + Guid.NewGuid().ToString("N"); + const string jwtRedirectUri = "http://localhost/both-jwt-callback"; + await CreateOAuthClientAsync( + jwtClientId, jwtSecret, jwtRedirectUri, [app.Id], + ["openid", "permissions", scopeName], AccessTokenType.Jwt); + + var referenceSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + var referenceClientId = "test-both-reference-" + Guid.NewGuid().ToString("N"); + const string referenceRedirectUri = "http://localhost/both-reference-callback"; + await CreateOAuthClientAsync( + referenceClientId, referenceSecret, referenceRedirectUri, [app.Id], + ["openid", "permissions", scopeName], AccessTokenType.Reference); + + var introspectionSecret = "TestClientSecret_" + Guid.NewGuid().ToString("N"); + await CreateOAuthClientAsync( + audience, + introspectionSecret, + "http://localhost/both-rs-callback", + [app.Id], + ["openid"]); + + var user = await Factory.CreateTestUserWithIdentityAsync( + firstname: "Both", + lastname: "RS", + acronym: "brs", + email: "brs@test.com", + password: "TestPass1234"); + await GrantAsync( + user.Id, + roleAppSlug: "rs-bothapp", + resourceType: "policy", + actions: ["read"], + groupBoundTo: ["rs-bothapp"]); + + var jwt = await DriveAuthCodeFlowAsync( + username: "brs", + password: "TestPass1234", + clientId: jwtClientId, + clientSecret: jwtSecret, + redirectUri: jwtRedirectUri, + scope: $"openid permissions {scopeName}", + resources: [audience]); + var referenceToken = await DriveAuthCodeFlowAsync( + username: "brs", + password: "TestPass1234", + clientId: referenceClientId, + clientSecret: referenceSecret, + redirectUri: referenceRedirectUri, + scope: $"openid permissions {scopeName}", + resources: [audience]); + + Assert.Contains('.', jwt); + Assert.DoesNotContain('.', referenceToken); + + using var rsHost = await BuildBothTokenResourceServerAsync( + audience, + introspectionSecret); + var rs = rsHost.GetTestClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", jwt)).StatusCode); + Assert.Equal( + HttpStatusCode.OK, + (await SendWithTokenAsync(rs, "/policy/read", referenceToken)).StatusCode); + } + [Fact] public async Task ReferenceClient_TokenFormat_IsNotLeaked_By_A_Prior_JwtClient() { @@ -1207,14 +1327,55 @@ private static async Task SendWithTokenAsync(HttpClient cli return await client.SendAsync(req, TestContext.Current.CancellationToken); } + private async Task BuildJwtResourceServerAsync(string audience) + { + var host = new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; + options.Audience = audience; + options.RequireHttpsMetadata = false; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.BackchannelHttpHandler = Factory.Server.CreateHandler(); + }; + }); + services.AddAuthorization(); + }) + .Configure(builder => + { + builder.UseRouting(); + builder.UseAuthentication(); + builder.UseAuthorization(); + builder.UseEndpoints(endpoints => + { + endpoints.MapGet("/policy/read", () => Results.Ok()) + .RequireModgudPermission("policy:read"); + + endpoints.MapGet("/policy/admin", () => Results.Ok()) + .RequireModgudPermission("policy:admin"); + }); + })) + .Build(); + + await host.StartAsync(); + return host; + } + /// - /// Boots a minimal in-memory resource-server host over the published client - /// library exactly as the reference-token sample (Modgud.TestApps.ResourceApi - /// with TESTAPPS:TOKENMODE=reference) does — AddModgudReferenceTokenClient - /// plus RequiresModgudPermission gates — so the opaque-token path is + /// Boots a minimal in-memory resource-server host over the published package + /// exactly as the reference-token sample (Modgud.TestApps.ResourceApi + /// with TESTAPPS:TOKENMODE=reference) does — AddModgudResourceServer + /// plus RequireModgudPermission gates — so the opaque-token path is /// exercised end-to-end against the in-memory IdP. /// - private static async Task BuildReferenceTokenResourceServerAsync(string audience, string introspectionSecret) + private async Task BuildReferenceTokenResourceServerAsync(string audience, string introspectionSecret) { var host = new HostBuilder() .ConfigureWebHost(web => web @@ -1222,14 +1383,16 @@ private static async Task BuildReferenceTokenResourceServerAsync(string a .ConfigureServices(services => { services.AddRouting(); - services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "http://localhost"; // introspection Host = the test realm - o.Audience = audience; // == the introspection client_id - o.IntrospectionClientSecret = introspectionSecret; - }); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; // test realm host + options.Audience = audience; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = introspectionSecret; + options.RequireHttpsMetadata = false; + }); + services.AddHttpClient(ModgudHttpClientNames.Introspection) + .ConfigurePrimaryHttpMessageHandler(() => Factory.Server.CreateHandler()); services.AddAuthorization(); }) .Configure(builder => @@ -1241,15 +1404,59 @@ private static async Task BuildReferenceTokenResourceServerAsync(string a { endpoints.MapGet("/me", (ClaimsPrincipal user) => Results.Ok(new { - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType) + permissions = user.FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value).ToArray(), })).RequireAuthorization(); endpoints.MapGet("/policy/read", () => Results.Ok()) - .RequireAuthorization().RequiresModgudPermission("policy:read"); + .RequireModgudPermission("policy:read"); endpoints.MapGet("/policy/admin", () => Results.Ok()) - .RequireAuthorization().RequiresModgudPermission("policy:admin"); + .RequireModgudPermission("policy:admin"); + }); + })) + .Build(); + + await host.StartAsync(); + return host; + } + + private async Task BuildBothTokenResourceServerAsync( + string audience, + string introspectionSecret) + { + var host = new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddModgudResourceServer(options => + { + options.Authority = "http://localhost"; + options.Audience = audience; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = introspectionSecret; + options.RequireHttpsMetadata = false; + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.BackchannelHttpHandler = Factory.Server.CreateHandler(); + }; + }); + services.AddHttpClient(ModgudHttpClientNames.Introspection) + .ConfigurePrimaryHttpMessageHandler(() => Factory.Server.CreateHandler()); + services.AddAuthorization(); + }) + .Configure(builder => + { + builder.UseRouting(); + builder.UseAuthentication(); + builder.UseAuthorization(); + builder.UseEndpoints(endpoints => + { + endpoints.MapGet("/policy/read", () => Results.Ok()) + .RequireModgudPermission("policy:read"); }); })) .Build(); diff --git a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj index 67c2e282..59538357 100644 --- a/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj +++ b/src/dotnet/Modgud.Api.Tests/Modgud.Api.Tests.csproj @@ -37,9 +37,9 @@ - - + diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs similarity index 98% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs index cbae2544..072e4d35 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopProofValidator.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopProofValidator.cs @@ -1,7 +1,7 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── @@ -11,7 +11,7 @@ using System.Text; using System.Text.Json; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; ///
/// Validates a DPoP proof JWT (RFC 9449 §4.3): a compact JWS sent in the @@ -30,7 +30,7 @@ namespace Modgud.Client.AspNetCore.Dpop; /// jti replay detection is left to the caller (it needs a per-realm store /// and a TTL policy that live outside this crypto core). Keeping it side-effect /// free is what lets the identical file be duplicated into the dependency-light -/// Modgud.Client.AspNetCore NuGet for the resource-server side. +/// Modgud.AspNetCore.ResourceServer NuGet for the resource-server side. /// /// /// diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs similarity index 98% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs index 7ee35922..a8581440 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopResourceValidator.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopResourceValidator.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// Outcome of validating a DPoP proof presented at a resource server. internal enum DpopResourceResult diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs index bf94a807..a621f30c 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/DpopValidationResult.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/DpopValidationResult.cs @@ -1,12 +1,12 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// /// Why a DPoP proof was rejected (RFC 9449 §4.3 / §5). means diff --git a/src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs index 14c35542..bf42ba21 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Dpop/JwkThumbprint.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Dpop/JwkThumbprint.cs @@ -1,7 +1,7 @@ // ───────────────────────────────────────────────────────────────────────── // DUPLICATED, KEEP IN SYNC. Verbatim copy (namespace aside) of // Modgud.Infrastructure/OpenIddict/Dpop/. The resource-server side -// needs the identical DPoP crypto, but this client library is a published NuGet +// needs the identical DPoP crypto, but this resource-server library is a published NuGet // kept deliberately dependency-light, so the code is duplicated rather than // shared. Any change to the server-side original MUST be mirrored here. // ───────────────────────────────────────────────────────────────────────── @@ -10,7 +10,7 @@ using System.Security.Cryptography; using System.Text; -namespace Modgud.Client.AspNetCore.Dpop; +namespace Modgud.AspNetCore.ResourceServer.Dpop; /// /// RFC 7638 JWK thumbprint (SHA-256, base64url) for the two key types DPoP @@ -38,7 +38,7 @@ namespace Modgud.Client.AspNetCore.Dpop; /// /// Kept dependency-free (BCL only: + /// ) so the exact same file can be -/// duplicated verbatim into the dependency-light Modgud.Client.AspNetCore +/// duplicated verbatim into the dependency-light Modgud.AspNetCore.ResourceServer /// NuGet for resource-server-side validation. Any change here MUST be mirrored /// there — see the "keep in sync" note on the client copy. /// diff --git a/src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj b/src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj similarity index 83% rename from src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj rename to src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj index a19fbfa8..741880ad 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/Modgud.Client.AspNetCore.csproj +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/Modgud.AspNetCore.ResourceServer.csproj @@ -1,14 +1,13 @@ - Modgud.Client.AspNetCore + Modgud.AspNetCore.ResourceServer ASP.NET Core integration for Modgud resource servers. Validates - either JWT access tokens (AddModgudClient, on top of AddJwtBearer) - or Modgud's default opaque reference tokens via introspection - (AddModgudReferenceTokenClient), then surfaces the per-audience + JWT access tokens, opaque reference tokens via introspection, or both + through one AddModgudResourceServer registration, then surfaces the per-audience resource_access roles/permissions as flat claims so - [Authorize(Roles="...")] and .RequiresModgudPermission("...") work + [Authorize(Roles="...")] and .RequireModgudPermission("...") work natively. Bypass tiers are pre-expanded by the IdP, so the lib does pure exact-match — no evaluator logic. @@ -19,7 +18,7 @@ source-of-truth for versioning is the git tag, not a checked-in string. --> - Modgud.Client.AspNetCore + Modgud.AspNetCore.ResourceServer Cocoar Cocoar Copyright © Cocoar @@ -52,7 +51,7 @@ + to project resource_access onto the validated principal. --> + and routes the named introspection HttpClient to the in-memory IdP. --> diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs new file mode 100644 index 00000000..81e33af2 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudClaimsProjector.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using System.Text.Json; + +namespace Modgud.AspNetCore.ResourceServer; + +/// Claim names projected by the Modgud resource-server package. +public static class ModgudClaimTypes +{ + /// A concrete <resource>:<action> permission. + public const string Permission = "permission"; + + /// The per-audience authorization object emitted by Modgud. + public const string ResourceAccess = "resource_access"; +} + +/// +/// Projects one scheme's configured audience block directly onto its +/// authenticated identity. This deliberately does not use +/// IClaimsTransformation: the audience belongs to the authentication +/// scheme that validated the token, not to global application state. +/// +internal static class ModgudClaimsProjector +{ + public static void Project(ClaimsPrincipal? principal, string audience) + { + if (principal?.Identity is not ClaimsIdentity identity || + !identity.IsAuthenticated || + string.IsNullOrWhiteSpace(audience)) + { + return; + } + + var raw = identity.FindFirst(ModgudClaimTypes.ResourceAccess)?.Value; + if (string.IsNullOrWhiteSpace(raw) || + !TryParseJson(raw, out var resourceAccess) || + resourceAccess.ValueKind != JsonValueKind.Object || + !resourceAccess.TryGetProperty(audience, out var audienceBlock) || + audienceBlock.ValueKind != JsonValueKind.Object) + { + return; + } + + FlattenStringArray(identity, audienceBlock, "roles", ClaimTypes.Role); + FlattenStringArray(identity, audienceBlock, "permissions", ModgudClaimTypes.Permission); + } + + private static void FlattenStringArray( + ClaimsIdentity identity, + JsonElement audienceBlock, + string property, + string claimType) + { + if (!audienceBlock.TryGetProperty(property, out var array) || + array.ValueKind != JsonValueKind.Array) + { + return; + } + + var existing = new HashSet( + identity.FindAll(claimType).Select(c => c.Value), + StringComparer.Ordinal); + + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String) continue; + var value = element.GetString(); + if (string.IsNullOrEmpty(value) || !existing.Add(value)) continue; + identity.AddClaim(new Claim(claimType, value)); + } + } + + private static bool TryParseJson(string raw, out JsonElement element) + { + try + { + using var document = JsonDocument.Parse(raw); + element = document.RootElement.Clone(); + return true; + } + catch (JsonException) + { + element = default; + return false; + } + } +} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs similarity index 96% rename from src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs rename to src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs index c9624678..1ddc5217 100644 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudDpopJwtBearer.cs +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudDpopJwtBearer.cs @@ -3,9 +3,9 @@ using System.Text.Json; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; -using Modgud.Client.AspNetCore.Dpop; +using Modgud.AspNetCore.ResourceServer.Dpop; -namespace Modgud.Client.AspNetCore; +namespace Modgud.AspNetCore.ResourceServer; /// /// Resource-server DPoP enforcement for the JWT-bearer validation path @@ -13,7 +13,7 @@ namespace Modgud.Client.AspNetCore; /// . /// /// Two hooks into the JwtBearer pipeline, wired by -/// : +/// AddModgudResourceServer in a JWT-capable mode: /// /// OnMessageReceived () — a /// DPoP-bound token is presented under the DPoP auth scheme, not @@ -150,8 +150,8 @@ public static BindingResult EvaluateBinding(HttpRequest request, ClaimsPrincipal /// /// OnTokenValidated hook: runs and, on any /// rejection, fails the authentication (→ 401) with an RFC-flavoured reason. - /// A pass leaves the context untouched so downstream handlers (UserInfo - /// enrichment) continue. + /// A pass leaves the context untouched so scheme-local claims projection + /// can continue. /// public static void EnforceBinding(TokenValidatedContext context) { diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs new file mode 100644 index 00000000..ef43282d --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudIntrospectionHandler.cs @@ -0,0 +1,194 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Modgud.AspNetCore.ResourceServer; + +internal sealed class ModgudIntrospectionHandler : AuthenticationHandler +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ModgudIntrospectionHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IHttpClientFactory httpClientFactory) + : base(options, logger, encoder) + { + _httpClientFactory = httpClientFactory; + } + + protected override async Task HandleAuthenticateAsync() + { + var rawAuthorization = Request.Headers.Authorization.ToString(); + if (string.IsNullOrEmpty(rawAuthorization) || + !AuthenticationHeaderValue.TryParse(rawAuthorization, out var header) || + string.IsNullOrEmpty(header.Parameter)) + { + return AuthenticateResult.NoResult(); + } + + var isBearer = string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase); + var isDpop = string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase); + if (!isBearer && !isDpop) + return AuthenticateResult.NoResult(); + + var client = _httpClientFactory.CreateClient(ModgudHttpClientNames.Introspection); + var principal = await ModgudTokenIntrospection.IntrospectAsync( + client, + Options, + header.Parameter, + Scheme.Name, + Logger, + Context.RequestAborted); + if (principal is null) + return AuthenticateResult.Fail("Modgud introspection did not affirmatively validate the token."); + + var boundJkt = principal.FindFirst(Dpop.DpopResource.ConfirmationJktClaimType)?.Value; + if (isDpop) + { + if (string.IsNullOrEmpty(boundJkt)) + return AuthenticateResult.Fail("The DPoP scheme was used but the token is not DPoP-bound."); + + var outcome = Dpop.DpopResourceValidator.Validate( + Request, + header.Parameter, + boundJkt, + DateTimeOffset.UtcNow); + if (outcome != Dpop.DpopResourceResult.Valid) + return AuthenticateResult.Fail($"The DPoP proof did not validate ({outcome})."); + } + else if (!string.IsNullOrEmpty(boundJkt)) + { + return AuthenticateResult.Fail( + "This access token is DPoP-bound and must be presented with the DPoP scheme."); + } + + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } +} + +internal static class ModgudTokenIntrospection +{ + public static async Task IntrospectAsync( + HttpClient client, + ModgudIntrospectionOptions options, + string token, + string authenticationType, + ILogger logger, + CancellationToken ct) + { + var url = options.Authority.TrimEnd('/') + "/connect/introspect"; + using var content = new FormUrlEncodedContent( + [ + new("token", token), + new("token_type_hint", "access_token"), + new("client_id", options.ClientId), + new("client_secret", options.ClientSecret), + ]); + + string body; + try + { + using var response = await client.PostAsync(url, content, ct); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug( + "Modgud: /connect/introspect returned {Status}; rejecting the token.", + (int)response.StatusCode); + return null; + } + + body = await response.Content.ReadAsStringAsync(ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "Modgud: /connect/introspect call failed; rejecting the token."); + return null; + } + + return BuildPrincipal(body, options.Audience, authenticationType, logger); + } + + internal static ClaimsPrincipal? BuildPrincipal( + string introspectionBody, + string audience, + string authenticationType, + ILogger logger) + { + JsonElement root; + try + { + using var document = JsonDocument.Parse(introspectionBody); + root = document.RootElement.Clone(); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Modgud: /connect/introspect returned unparseable JSON; rejecting the token."); + return null; + } + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("active", out var active) || + active.ValueKind != JsonValueKind.True || + !AudienceContains(root, audience)) + { + return null; + } + + var identity = new ClaimsIdentity( + authenticationType, + nameType: "name", + roleType: ClaimTypes.Role); + + foreach (var property in root.EnumerateObject()) + { + switch (property.Name) + { + case "resource_access" when property.Value.ValueKind == JsonValueKind.Object: + identity.AddClaim(new Claim( + ModgudClaimTypes.ResourceAccess, + property.Value.GetRawText(), + Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json)); + break; + + case "cnf" when property.Value.ValueKind == JsonValueKind.Object && + property.Value.TryGetProperty("jkt", out var jkt) && + jkt.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim(Dpop.DpopResource.ConfirmationJktClaimType, jkt.GetString()!)); + break; + + case "sub" when property.Value.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim("sub", property.Value.GetString()!)); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, property.Value.GetString()!)); + break; + + case "name" or "preferred_username" or "email" or "scope" or "client_id" + when property.Value.ValueKind == JsonValueKind.String: + identity.AddClaim(new Claim(property.Name, property.Value.GetString()!)); + break; + } + } + + var principal = new ClaimsPrincipal(identity); + ModgudClaimsProjector.Project(principal, audience); + return principal; + } + + private static bool AudienceContains(JsonElement root, string audience) + { + if (!root.TryGetProperty("aud", out var audiences)) return false; + return audiences.ValueKind switch + { + JsonValueKind.String => string.Equals(audiences.GetString(), audience, StringComparison.Ordinal), + JsonValueKind.Array => audiences.EnumerateArray().Any( + item => item.ValueKind == JsonValueKind.String && + string.Equals(item.GetString(), audience, StringComparison.Ordinal)), + _ => false, + }; + } +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs new file mode 100644 index 00000000..6673ae03 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudPermissionExtensions.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Modgud.AspNetCore.ResourceServer; + +/// ASP.NET Core authorization helpers for Modgud permissions. +public static class ModgudPermissionExtensions +{ + /// + /// Requires an authenticated principal with the exact Modgud permission. + /// The requirement is attached as ASP.NET Core authorization metadata. + /// + public static RouteHandlerBuilder RequireModgudPermission( + this RouteHandlerBuilder builder, + string permission) + { + ArgumentNullException.ThrowIfNull(builder); + builder.RequireAuthorization(BuildPolicy(permission)); + return builder; + } + + /// Route-group variant of . + public static RouteGroupBuilder RequireModgudPermission( + this RouteGroupBuilder builder, + string permission) + { + ArgumentNullException.ThrowIfNull(builder); + builder.RequireAuthorization(BuildPolicy(permission)); + return builder; + } + + internal static AuthorizationPolicy BuildPolicy(string permission) + { + ArgumentException.ThrowIfNullOrWhiteSpace(permission); + return new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .RequireClaim(ModgudClaimTypes.Permission, permission) + .Build(); + } +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs new file mode 100644 index 00000000..b3e73e41 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ModgudResourceServerOptions.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace Modgud.AspNetCore.ResourceServer; + +/// The access-token formats accepted by a Modgud resource server. +public enum ModgudTokenMode +{ + /// Accept only self-contained JWT access tokens. + OnlyJwt, + + /// Accept only opaque reference tokens through introspection. + OnlyReferenceToken, + + /// Accept both formats and route each token to the matching validator. + Both, +} + +/// Defaults for the single Modgud resource-server authentication scheme. +public static class ModgudResourceServerDefaults +{ + /// The public scheme registered by AddModgudResourceServer. + public const string AuthenticationScheme = "Modgud"; +} + +/// Configuration for a Modgud-protected ASP.NET Core resource server. +public sealed class ModgudResourceServerOptions +{ + /// The realm host root, for example https://id.example.com. + public string Authority { get; set; } = string.Empty; + + /// The resource-server audience expected in every accepted token. + public string Audience { get; set; } = string.Empty; + + /// The accepted token format. Defaults to local JWT validation. + public ModgudTokenMode TokenMode { get; set; } = ModgudTokenMode.OnlyJwt; + + /// + /// Confidential introspection client ID. Defaults to . + /// Used only by and + /// . + /// + public string? IntrospectionClientId { get; set; } + + /// + /// Confidential introspection client secret. Required by + /// and + /// . + /// + public string? IntrospectionClientSecret { get; set; } + + /// + /// Requires an HTTPS authority. Disable only for local development. + /// + public bool RequireHttpsMetadata { get; set; } = true; + + /// + /// Optional advanced configuration applied to the internal JWT bearer + /// handler before Modgud wires DPoP and audience-local claims projection. + /// Used only by modes that accept JWTs. + /// + public Action? ConfigureJwtBearer { get; set; } +} + +internal sealed class ModgudIntrospectionOptions : AuthenticationSchemeOptions +{ + public string Authority { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; +} + +internal static class ModgudSchemeNames +{ + public const string Jwt = "Modgud.Internal.Jwt"; + public const string Introspection = "Modgud.Internal.Introspection"; +} diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md b/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md new file mode 100644 index 00000000..d45556f8 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/README.md @@ -0,0 +1,145 @@ +# Modgud.AspNetCore.ResourceServer + +ASP.NET Core integration for APIs protected by a +[Modgud](https://github.com/cocoar-dev/modgud) identity provider. + +The package has one registration method and one public authentication scheme. +`ModgudTokenMode` controls whether the API accepts self-contained JWTs, opaque +reference tokens, or both. In `Both` mode, the package routes three-part JWTs to +local validation and opaque tokens to RFC 7662 introspection. + +Both validation paths select `resource_access[]` and project its roles +and permissions onto the authenticated identity. Roles use `ClaimTypes.Role`; +permissions use `ModgudClaimTypes.Permission`. + +## Install + +```bash +dotnet add package Modgud.AspNetCore.ResourceServer +``` + +## JWT quickstart + +JWT is the default mode: + +```csharp +using Modgud.AspNetCore.ResourceServer; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; +}); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/admin/ping", () => "pong") + .RequireAuthorization(policy => policy.RequireRole("Editor")); + +app.MapPost("/calendars/{id}", (string id) => Results.Ok()) + .RequireModgudPermission("calendar:write"); + +app.Run(); +``` + +JWT mode makes no per-request call to Modgud. A token must contain the +configured audience and its `resource_access` block. There is deliberately no +UserInfo fallback. + +## Reference-token mode + +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` + +The resource server authenticates to `/connect/introspect` with a confidential +OAuth client. `IntrospectionClientId` defaults to `Audience`; in the usual setup +the introspection client's ID therefore equals the resource-server audience. +Validation is fail-closed and uncached, so revocation takes effect on the next +request. + +## Accept both formats + +```csharp +builder.Services.AddModgudResourceServer(options => +{ + options.Authority = "https://auth.example.com"; + options.Audience = "event-tree-api"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = + builder.Configuration["Modgud:IntrospectionSecret"]; +}); +``` + +The application still exposes one authentication scheme. Token shape only +selects the internal validator; it never bypasses signature, issuer, audience, +expiry, active-state, or DPoP validation. A second +`AddModgudResourceServer(...)` call is rejected. + +## Permission gates + +`RequireModgudPermission` adds normal ASP.NET Core authorization metadata. It +works on both `RouteHandlerBuilder` and `RouteGroupBuilder` and yields `401` for +anonymous callers or `403` for authenticated callers without the exact +permission: + +```csharp +var writeApi = app.MapGroup("/write") + .RequireModgudPermission("calendar:write"); +``` + +Bypass grants such as `realm:admin` and `:admin` are expanded by the +IdP before token issuance. The resource server performs only an exact claim +check. + +## Claims + +Given: + +```json +"resource_access": { + "event-tree-api": { + "roles": ["Editor"], + "permissions": ["calendar:read", "calendar:write"] + } +} +``` + +read the projected values with: + +```csharp +var roles = user.FindAll(ClaimTypes.Role).Select(claim => claim.Value); +var permissions = user.FindAll(ModgudClaimTypes.Permission) + .Select(claim => claim.Value); +``` + +## Configuration + +| Option | Description | +| --- | --- | +| `Authority` | Required realm host root. | +| `Audience` | Required token audience and `resource_access` key. | +| `TokenMode` | `OnlyJwt` (default), `OnlyReferenceToken`, or `Both`. | +| `IntrospectionClientId` | Introspection client ID; defaults to `Audience`. | +| `IntrospectionClientSecret` | Required when the mode accepts reference tokens. | +| `RequireHttpsMetadata` | Requires an HTTPS authority; defaults to `true`. | +| `ConfigureJwtBearer` | Optional advanced JWT bearer configuration in JWT-capable modes. | + +The valid option combination is checked immediately during registration. +`required` properties cannot express the mode-dependent secret requirement, so +invalid combinations fail with `OptionsValidationException`. + +## License + +Apache-2.0. See [LICENSE](https://github.com/cocoar-dev/modgud/blob/develop/LICENSE). diff --git a/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..823e1348 --- /dev/null +++ b/src/dotnet/Modgud.AspNetCore.ResourceServer/ServiceCollectionExtensions.cs @@ -0,0 +1,260 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Modgud.AspNetCore.ResourceServer; + +/// Authentication registration for Modgud resource servers. +public static class ServiceCollectionExtensions +{ + /// + /// Registers the single Modgud resource-server scheme. The selected + /// determines whether + /// JWTs, reference tokens, or both are accepted. + /// + public static AuthenticationBuilder AddModgudResourceServer( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + if (services.Any(registration => + registration.ServiceType == typeof(ModgudResourceServerRegistrationMarker))) + { + throw new InvalidOperationException( + "AddModgudResourceServer can only be called once. Select OnlyJwt, " + + "OnlyReferenceToken, or Both through ModgudResourceServerOptions.TokenMode."); + } + + var options = new ModgudResourceServerOptions(); + configure(options); + Validate(options); + + services.AddSingleton(); + services.AddAuthorization(); + + var authentication = services.AddAuthentication( + ModgudResourceServerDefaults.AuthenticationScheme); + + switch (options.TokenMode) + { + case ModgudTokenMode.OnlyJwt: + AddJwt(authentication, ModgudResourceServerDefaults.AuthenticationScheme, options); + break; + + case ModgudTokenMode.OnlyReferenceToken: + AddIntrospection( + authentication, + ModgudResourceServerDefaults.AuthenticationScheme, + options); + break; + + case ModgudTokenMode.Both: + authentication.AddPolicyScheme( + ModgudResourceServerDefaults.AuthenticationScheme, + displayName: null, + policy => + { + policy.ForwardDefaultSelector = context => + SelectTokenScheme(context.Request.Headers.Authorization); + }); + AddJwt(authentication, ModgudSchemeNames.Jwt, options); + AddIntrospection(authentication, ModgudSchemeNames.Introspection, options); + break; + + default: + throw new InvalidOperationException("Unsupported Modgud token mode."); + } + + return authentication; + } + + internal static bool LooksLikeModgudJwt(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader) || + !AuthenticationHeaderValue.TryParse(authorizationHeader, out var header) || + string.IsNullOrWhiteSpace(header.Parameter) || + (!string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + var token = header.Parameter; + var firstDot = token.IndexOf('.'); + if (firstDot <= 0) return false; + var secondDot = token.IndexOf('.', firstDot + 1); + return secondDot > firstDot + 1 && + secondDot < token.Length - 1 && + token.IndexOf('.', secondDot + 1) < 0; + } + + internal static string SelectTokenScheme(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader) || + !AuthenticationHeaderValue.TryParse(authorizationHeader, out var header) || + string.IsNullOrWhiteSpace(header.Parameter) || + (!string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) && + !string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase))) + { + return ModgudSchemeNames.Jwt; + } + + return LooksLikeModgudJwt(authorizationHeader) + ? ModgudSchemeNames.Jwt + : ModgudSchemeNames.Introspection; + } + + private static void AddJwt( + AuthenticationBuilder authentication, + string scheme, + ModgudResourceServerOptions resourceServer) + { + authentication.AddJwtBearer(scheme, options => + { + resourceServer.ConfigureJwtBearer?.Invoke(options); + options.Authority = resourceServer.Authority; + options.Audience = resourceServer.Audience; + options.RequireHttpsMetadata = resourceServer.RequireHttpsMetadata; + WireJwtEvents(options, resourceServer.Audience); + }); + + authentication.Services.AddOptions(scheme) + .Validate( + options => options.EventsType is null, + "EventsType is not supported. Configure callbacks through " + + "ModgudResourceServerOptions.ConfigureJwtBearer and JwtBearerOptions.Events.") + .ValidateOnStart(); + } + + private static void AddIntrospection( + AuthenticationBuilder authentication, + string scheme, + ModgudResourceServerOptions resourceServer) + { + authentication.Services.AddHttpClient(ModgudHttpClientNames.Introspection); + authentication.AddScheme( + scheme, + options => + { + options.Authority = resourceServer.Authority; + options.Audience = resourceServer.Audience; + options.ClientId = string.IsNullOrWhiteSpace(resourceServer.IntrospectionClientId) + ? resourceServer.Audience + : resourceServer.IntrospectionClientId; + options.ClientSecret = resourceServer.IntrospectionClientSecret!; + }); + } + + private static void WireJwtEvents(JwtBearerOptions options, string audience) + { + options.Events ??= new JwtBearerEvents(); + + var existingMessageReceived = options.Events.OnMessageReceived; + options.Events.OnMessageReceived = async context => + { + if (existingMessageReceived is not null) + await existingMessageReceived(context); + + if (context.Result is null && + string.IsNullOrEmpty(context.Token) && + ModgudDpopJwtBearer.ExtractDpopSchemeToken(context.HttpContext.Request) is { } token) + { + context.Token = token; + } + }; + + var existingTokenValidated = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = async context => + { + if (existingTokenValidated is not null) + await existingTokenValidated(context); + if (context.Result is not null) return; + + ModgudDpopJwtBearer.EnforceBinding(context); + if (context.Result is null) + ModgudClaimsProjector.Project(context.Principal, audience); + }; + } + + private static void Validate(ModgudResourceServerOptions options) + { + var failures = new List(); + + if (!Enum.IsDefined(options.TokenMode)) + failures.Add("TokenMode must be OnlyJwt, OnlyReferenceToken, or Both."); + if (string.IsNullOrWhiteSpace(options.Authority)) + failures.Add("Authority is required."); + else if (!IsValidAuthority(options.Authority, options.RequireHttpsMetadata)) + { + failures.Add( + "Authority must be an absolute HTTP(S) realm host root without a path, " + + "query, or fragment. HTTPS is required unless RequireHttpsMetadata=false."); + } + + if (string.IsNullOrWhiteSpace(options.Audience)) + failures.Add("Audience is required."); + + var acceptsReferenceTokens = + options.TokenMode is ModgudTokenMode.OnlyReferenceToken or ModgudTokenMode.Both; + if (acceptsReferenceTokens && + string.IsNullOrWhiteSpace(options.IntrospectionClientSecret)) + { + failures.Add( + "IntrospectionClientSecret is required when TokenMode accepts reference tokens."); + } + + if (!acceptsReferenceTokens && + (!string.IsNullOrWhiteSpace(options.IntrospectionClientId) || + !string.IsNullOrWhiteSpace(options.IntrospectionClientSecret))) + { + failures.Add( + "Introspection credentials cannot be configured when TokenMode is OnlyJwt."); + } + + if (options.TokenMode == ModgudTokenMode.OnlyReferenceToken && + options.ConfigureJwtBearer is not null) + { + failures.Add( + "ConfigureJwtBearer cannot be set when TokenMode is OnlyReferenceToken."); + } + + if (failures.Count > 0) + { + throw new OptionsValidationException( + ModgudResourceServerDefaults.AuthenticationScheme, + typeof(ModgudResourceServerOptions), + failures); + } + } + + private static bool IsValidAuthority(string authority, bool requireHttps) + { + if (!Uri.TryCreate(authority, UriKind.Absolute, out var uri)) return false; + var isHttps = string.Equals( + uri.Scheme, + Uri.UriSchemeHttps, + StringComparison.OrdinalIgnoreCase); + var isHttp = string.Equals( + uri.Scheme, + Uri.UriSchemeHttp, + StringComparison.OrdinalIgnoreCase); + + return (isHttps || isHttp) && + (!requireHttps || isHttps) && + string.IsNullOrEmpty(uri.UserInfo) && + (string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == "/") && + string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment); + } + + private sealed class ModgudResourceServerRegistrationMarker; +} + +internal static class ModgudHttpClientNames +{ + public const string Introspection = "Modgud.ResourceServer.Introspection"; +} diff --git a/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj b/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj index 8adac1fc..cbc57b26 100644 --- a/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj +++ b/src/dotnet/Modgud.Authorization/Modgud.Authorization.csproj @@ -17,9 +17,10 @@ + assembly so downstream consumers that evaluate raw grants can reuse + the logic without pulling Marten/Wolverine/JsEval transitively. The + resource-server package receives pre-expanded concrete permissions + and deliberately does not reference this assembly. --> diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs deleted file mode 100644 index 04c4a127..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudClaimsTransformation.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Pre-request claims-transformation that flattens -/// resource_access[] from -/// the principal's claims into flat and -/// "permission" claims so downstream gates work without per-endpoint -/// plumbing. -/// -/// Source of the data — preference order: the access token's own -/// embedded resource_access claim wins (federation v1.1 bakes it in -/// at issuance), with /connect/userinfo as a fallback for tokens -/// that don't carry it (see ). Both -/// paths land the claim on the identity the exact same way: JwtBearer's -/// token handler maps a JSON payload property to a claim of type -/// "resource_access" whose Value is the raw JSON text and -/// whose ValueType is -/// Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json -/// ("JSON"); the enricher's UserInfo fallback adds a claim with the -/// same type and a raw-JSON-text value. This transformer only ever reads -/// , so it is indifferent to which path populated -/// the claim or to — it just needs valid JSON -/// text under the "resource_access" claim type. Because UserInfo -/// only ever echoes the same block the token already carries (never a wider -/// or narrower one), preferring the token claim changes nothing about what -/// ends up on the principal — it only removes a redundant HTTP round-trip -/// for tokens that already have the claim. -/// -/// Idempotent: a second pass on the same identity does not duplicate -/// claims. -/// -/// The IdP pre-expands bypass tiers before emission, so this lib -/// performs no realm:admin / <r>:admin walk — -/// just reads the -/// "permission" claims and does contains(...). -/// -public sealed class ModgudClaimsTransformation : IClaimsTransformation -{ - /// Claim type for permission strings ("<resource>:<action>"). - public const string PermissionClaimType = "permission"; - - /// - /// Claim type that USED to carry flattened group names. - /// - /// - /// Quarantined in federation v1 (hub boundary): the Modgud IdP never emits a - /// groups block in resource_access — group membership is purely - /// IdP-internal and is expanded into roles/permissions before emission. This - /// transformer therefore never produces a claim of this type. The constant is - /// retained for binary compatibility and will be removed in a future major - /// version. Gate on roles/permissions instead. - /// - [Obsolete("Hub boundary: the Modgud IdP never emits groups in resource_access, " + - "so no claim of this type is ever produced. Gate on roles/permissions instead. " + - "Retained for binary compatibility; removed in a future major version.")] - public const string GroupClaimType = "group"; - - /// The standard OIDC/Keycloak UserInfo claim that nests per-RS authz info. - public const string ResourceAccessClaimType = "resource_access"; - - private readonly ModgudOptions _options; - - public ModgudClaimsTransformation(IOptions options) - { - _options = options.Value; - if (string.IsNullOrWhiteSpace(_options.Audience)) - throw new InvalidOperationException( - "ModgudOptions.Audience must be set to the resource server's audience " + - "(same value as JwtBearerOptions.Audience). Configure it via AddModgudClient."); - } - - public Task TransformAsync(ClaimsPrincipal principal) - { - if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) - return Task.FromResult(principal); - - var raw = identity.FindFirst(ResourceAccessClaimType)?.Value; - if (string.IsNullOrEmpty(raw)) - return Task.FromResult(principal); - - if (!TryParseJson(raw, out var resourceAccess) || - resourceAccess.ValueKind != JsonValueKind.Object) - return Task.FromResult(principal); - - if (!resourceAccess.TryGetProperty(_options.Audience, out var audienceBlock) || - audienceBlock.ValueKind != JsonValueKind.Object) - return Task.FromResult(principal); - - FlattenStringArray(identity, audienceBlock, "roles", ClaimTypes.Role); - FlattenStringArray(identity, audienceBlock, "permissions", PermissionClaimType); - // Federation v1 hub boundary: the IdP never emits a "groups" block here - // (group membership is IdP-internal, expanded into roles/permissions before - // emission), so there is nothing to flatten. The legacy group flattener was - // removed; GroupClaimType is retained [Obsolete] for binary compatibility. - - return Task.FromResult(principal); - } - - /// - /// Adds each string in [] - /// as a claim. Skips duplicates so a second - /// pipeline pass doesn't bloat the identity. - /// - private static void FlattenStringArray( - ClaimsIdentity identity, JsonElement audienceBlock, string property, string claimType) - { - if (!audienceBlock.TryGetProperty(property, out var array) || - array.ValueKind != JsonValueKind.Array) - return; - - var existing = new HashSet( - identity.FindAll(claimType).Select(c => c.Value), - StringComparer.Ordinal); - - foreach (var element in array.EnumerateArray()) - { - var value = element.GetString(); - if (string.IsNullOrEmpty(value) || !existing.Add(value)) continue; - identity.AddClaim(new Claim(claimType, value)); - } - } - - private static bool TryParseJson(string raw, out JsonElement element) - { - try - { - using var doc = JsonDocument.Parse(raw); - element = doc.RootElement.Clone(); - return true; - } - catch (JsonException) - { - element = default; - return false; - } - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs deleted file mode 100644 index 882af01c..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudIntrospectionHandler.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Validates opaque Modgud reference access tokens by calling the IdP's -/// /connect/introspect endpoint (RFC 7662) and projecting the response -/// onto a — including the per-audience -/// resource_access block, which the shared -/// then flattens into role / -/// permission claims exactly as for the JWT path. -/// -/// Fail-closed. Unlike (which -/// enriches an already-validated JWT and so fails open on a UserInfo outage), -/// introspection is the validation here. A non-2xx response, a -/// transport error, an active:false body, or an audience mismatch all -/// reject the request — a token that can't be affirmatively validated is not -/// honoured. -/// -internal sealed class ModgudIntrospectionHandler : AuthenticationHandler -{ - public ModgudIntrospectionHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : base(options, logger, encoder) - { - } - - protected override async Task HandleAuthenticateAsync() - { - var rawAuth = Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(rawAuth) || - !AuthenticationHeaderValue.TryParse(rawAuth, out var header) || - string.IsNullOrEmpty(header.Parameter)) - { - // No credentials → this handler has no opinion; the pipeline treats - // the request as anonymous (a 401 challenge follows only if the - // endpoint requires authorization). - return AuthenticateResult.NoResult(); - } - - var isBearer = string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase); - var isDpop = string.Equals(header.Scheme, Dpop.DpopResource.Scheme, StringComparison.OrdinalIgnoreCase); - if (!isBearer && !isDpop) - return AuthenticateResult.NoResult(); - - var principal = await ModgudTokenIntrospection.IntrospectAsync( - Options, header.Parameter!, Scheme.Name, Logger, Context.RequestAborted); - if (principal is null) - return AuthenticateResult.Fail("Modgud introspection did not affirmatively validate the token."); - - // Enforce DPoP binding (RFC 9449 §7.1): a sender-constrained token - // (cnf.jkt present) MUST be presented with the DPoP scheme AND a proof - // whose key matches; a bound token used as a plain bearer token is - // rejected. A DPoP-scheme request against an unbound token is likewise - // rejected — the client is asserting a possession the token doesn't carry. - var boundJkt = principal.FindFirst(Dpop.DpopResource.ConfirmationJktClaimType)?.Value; - if (isDpop) - { - if (string.IsNullOrEmpty(boundJkt)) - return AuthenticateResult.Fail("The DPoP scheme was used but the token is not DPoP-bound."); - - var outcome = Dpop.DpopResourceValidator.Validate( - Request, header.Parameter!, boundJkt, DateTimeOffset.UtcNow); - if (outcome != Dpop.DpopResourceResult.Valid) - return AuthenticateResult.Fail($"The DPoP proof did not validate ({outcome})."); - } - else if (!string.IsNullOrEmpty(boundJkt)) - { - return AuthenticateResult.Fail( - "This access token is DPoP-bound and must be presented with the DPoP scheme."); - } - - return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); - } -} - -/// -/// The pure introspection + claims-projection logic behind -/// , factored out so it can be unit -/// tested against a stub HTTP handler without standing up the auth pipeline. -/// -internal static class ModgudTokenIntrospection -{ - // Settable seam so unit tests can substitute a fake HttpMessageHandler and - // assert on the introspection request. Production callers never touch it. - internal static HttpClient SharedClient { get; set; } = new(); - - /// - /// Introspects and, if it is active and audience-valid, - /// returns a principal carrying the introspection claims (including the raw - /// resource_access claim the transformation reads). Returns - /// null on any failure — the caller treats that as "reject". - /// - public static async Task IntrospectAsync( - ModgudReferenceTokenOptions options, - string token, - string authenticationType, - ILogger logger, - CancellationToken ct) - { - var url = options.Authority.TrimEnd('/') + "/connect/introspect"; - // Form-body client authentication (client_secret_post). A URL-shaped - // client_id (the MCP audience case) collides with HTTP Basic, which - // splits client_id:secret on the scheme colon. - using var content = new FormUrlEncodedContent(new[] - { - new KeyValuePair("token", token), - new KeyValuePair("token_type_hint", "access_token"), - new KeyValuePair("client_id", options.ResolvedClientId), - new KeyValuePair("client_secret", options.IntrospectionClientSecret ?? string.Empty), - }); - - string body; - try - { - using var response = await SharedClient.PostAsync(url, content, ct); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug( - "Modgud: /connect/introspect returned {Status}; rejecting the token.", - (int)response.StatusCode); - return null; - } - body = await response.Content.ReadAsStringAsync(ct); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - // Fail-closed: introspection is the validation, so an outage means - // we cannot affirm the token — reject rather than admit it. - logger.LogWarning(ex, "Modgud: /connect/introspect call failed; rejecting the token."); - return null; - } - - return BuildPrincipal(body, options.Audience, authenticationType, logger); - } - - /// Projects an introspection response body onto a principal, or - /// returns null when the token is inactive, malformed, or not for - /// this audience. - internal static ClaimsPrincipal? BuildPrincipal( - string introspectionBody, string audience, string authenticationType, ILogger logger) - { - JsonElement root; - try - { - using var doc = JsonDocument.Parse(introspectionBody); - root = doc.RootElement.Clone(); - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Modgud: /connect/introspect returned unparseable JSON; rejecting the token."); - return null; - } - - if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("active", out var active) || - active.ValueKind != JsonValueKind.True) - { - // active:false (or missing) — RFC 7662 §2.2. Nothing else is trustworthy. - return null; - } - - // Defence in depth: only honour a token that names this RS in its aud. - // (The IdP already gates this — it returns active:false to a caller that - // isn't an audience/presenter — but a misconfigured introspection client - // id must never let a foreign-audience token through.) - if (!AudienceContains(root, audience)) - { - logger.LogWarning( - "Modgud: introspected token is active but its audience does not include '{Audience}'; rejecting.", - audience); - return null; - } - - var identity = new ClaimsIdentity( - authenticationType, nameType: "name", roleType: ClaimTypes.Role); - - foreach (var property in root.EnumerateObject()) - { - switch (property.Name) - { - // The load-bearing claim: keep the raw JSON so - // ModgudClaimsTransformation can flatten resource_access[audience]. - case "resource_access" when property.Value.ValueKind == JsonValueKind.Object: - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, - property.Value.GetRawText(), - Microsoft.IdentityModel.JsonWebTokens.JsonClaimValueTypes.Json)); - break; - - // RFC 9449 §6 — a DPoP-bound token carries cnf={"jkt":…}. Surface - // the thumbprint so the handler can require a matching proof. - case "cnf" when property.Value.ValueKind == JsonValueKind.Object && - property.Value.TryGetProperty("jkt", out var jkt) && - jkt.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim(Dpop.DpopResource.ConfirmationJktClaimType, jkt.GetString()!)); - break; - - // Standard string scalars worth surfacing on the principal. - case "sub" when property.Value.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim("sub", property.Value.GetString()!)); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, property.Value.GetString()!)); - break; - - case "name" or "preferred_username" or "email" or "scope" or "client_id" - when property.Value.ValueKind == JsonValueKind.String: - identity.AddClaim(new Claim(property.Name, property.Value.GetString()!)); - break; - } - } - - return new ClaimsPrincipal(identity); - } - - private static bool AudienceContains(JsonElement root, string audience) - { - if (!root.TryGetProperty("aud", out var aud)) return false; - return aud.ValueKind switch - { - JsonValueKind.String => string.Equals(aud.GetString(), audience, StringComparison.Ordinal), - JsonValueKind.Array => aud.EnumerateArray().Any( - e => e.ValueKind == JsonValueKind.String && - string.Equals(e.GetString(), audience, StringComparison.Ordinal)), - _ => false, - }; - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs deleted file mode 100644 index fb9bb2b9..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudOptions.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Modgud.Client.AspNetCore; - -/// -/// Configuration for the Modgud resource-server integration. -/// -/// The lib does two things on top of vanilla -/// AddJwtBearer: -/// -/// Wires a JwtBearerEvents.OnTokenValidated handler that -/// fetches {Authority}/connect/userinfo with the user's token -/// and adds the resource_access claim to the principal — pure -/// AddJwtBearer doesn't do this natively (UserInfo-fetching is -/// an AddOpenIdConnect feature). -/// Registers a ClaimsTransformation that reads -/// resource_access[] off the principal -/// and projects roles / permissions / groups onto flat -/// ClaimTypes.Role / "permission" / "group" claims -/// so endpoint filters + [Authorize(Roles=...)] work natively. -/// -/// -/// UserInfo emits permissions in their bypass-pre-expanded form -/// (the IdP already resolves realm:admin and <r>:admin -/// to concrete catalog strings), so the lib doesn't need to evaluate -/// bypass tiers itself — exact-match is sufficient. -/// -public sealed class ModgudOptions -{ - /// - /// The audience this resource server identifies as — same value the - /// JWT-bearer middleware compares the token's aud claim against - /// (options.Audience on AddJwtBearer). Used as the lookup - /// key into resource_access[…] on the principal's claims. - /// - /// Required. - /// - public string Audience { get; set; } = string.Empty; - - /// - /// IdP base URL used to construct the UserInfo URL - /// ({Authority}/connect/userinfo). Same value as - /// JwtBearerOptions.Authority. Trailing slashes are tolerated. - /// - /// Required. - /// - public string Authority { get; set; } = string.Empty; - - /// - /// Authentication scheme to attach the UserInfo-fetching handler to. - /// Defaults to "Bearer"; override if your host uses a custom - /// scheme name on AddJwtBearer(scheme, …). - /// - public string JwtBearerScheme { get; set; } = "Bearer"; -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs b/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs deleted file mode 100644 index 2bbf7dee..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ModgudReferenceTokenOptions.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.AspNetCore.Authentication; - -namespace Modgud.Client.AspNetCore; - -/// Well-known scheme name for the Modgud reference-token -/// (introspection) authentication handler. -public static class ModgudReferenceTokenDefaults -{ - /// The default authentication-scheme name registered by - /// AddModgudReferenceTokenClient. - public const string AuthenticationScheme = "ModgudIntrospection"; -} - -/// -/// Options for the reference-token (opaque access token) validation mode. -/// -/// Modgud's default access-token format is a reference token — -/// an opaque handle with no self-contained claims, validated by calling the -/// IdP's /connect/introspect endpoint (RFC 7662). This mode lets a -/// resource server accept those tokens directly, instead of requiring the -/// OAuth client to be switched to JWT access tokens for the JWKS-based -/// AddModgudClient path. -/// -/// Introspection identity. The IdP only reveals a token — its -/// active status and any per-audience resource_access block — -/// to a caller that is one of the token's audiences or its authorised -/// presenter. A resource server therefore introspects with a confidential -/// client whose client_id equals its own (the -/// RFC 8707 resource= value already carried in the token's aud). -/// That single introspection call both validates the token and returns the -/// audience-scoped roles/permissions — no separate UserInfo round-trip. -/// -/// No caching, by design. Every request introspects. A reference -/// token's defining advantage is instant revocation; a TTL cache would trade -/// that away. Caching may be added later as an explicit opt-in. -/// -public sealed class ModgudReferenceTokenOptions : AuthenticationSchemeOptions -{ - /// IdP base URL, e.g. https://auth.example.com — the realm - /// host root, no realm path segment. Used to build the - /// {Authority}/connect/introspect URL. Required. - public string Authority { get; set; } = string.Empty; - - /// The resource server's audience — the same value used as the - /// RFC 8707 resource= indicator when tokens are minted for this RS - /// (an OAuthApi name in Modgud). The audience block read out of the - /// introspection response is keyed by this value. Required. - public string Audience { get; set; } = string.Empty; - - /// The client_id used to authenticate the introspection - /// call. Defaults to — the RS registers a - /// confidential client under its own audience id so the IdP treats it as - /// an authorised introspector. Override only if the introspection client - /// is registered under a different id that is nonetheless one of the - /// token's audiences. - public string? IntrospectionClientId { get; set; } - - /// The client secret for . Required. - /// Sent as a form-body credential (client_secret_post), which works - /// for both URL-shaped and plain audience ids — HTTP Basic would break on - /// the scheme colon of a URL client_id. - public string? IntrospectionClientSecret { get; set; } - - /// The effective introspection client_id: - /// if set, otherwise - /// . - public string ResolvedClientId => - string.IsNullOrEmpty(IntrospectionClientId) ? Audience : IntrospectionClientId!; -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/README.md b/src/dotnet/Modgud.Client.AspNetCore/README.md deleted file mode 100644 index a5e0e91c..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# Modgud.Client.AspNetCore - -ASP.NET Core integration for resource servers that authenticate against a -[Modgud](https://github.com/cocoar-dev/modgud) identity provider. - -Whichever token format your OAuth client issues, the lib flattens the -per-audience `resource_access[]` block into native -`ClaimTypes.Role` / `"permission"` claims so `[Authorize(Roles = "...")]` -and an `.RequiresModgudPermission("...")` endpoint filter work natively. -Bypass tiers (`realm:admin`, `:admin`) are pre-expanded -**IdP-side** before emission, so the lib does pure exact-match — no -evaluator logic. - -It supports both Modgud access-token formats: - -- **JWT access tokens** — `AddModgudClient` on top of `AddJwtBearer`. - JwtBearer validates the token locally against the realm JWKS; the lib - reads `resource_access` from the token itself, falling back to - `{Authority}/connect/userinfo` only for tokens that don't carry it. -- **Reference (opaque) access tokens** — `AddModgudReferenceTokenClient`. - This is Modgud's **default** token format. Each request validates the - token via `{Authority}/connect/introspect` (RFC 7662) and reads - `resource_access` from the introspection response — one call, no - separate UserInfo round-trip. Validation is fail-closed and there is no - cache, so revocation is instant. - -## Install - -```bash -dotnet add package Modgud.Client.AspNetCore -``` - -## Quickstart — JWT access tokens - -Requires the OAuth client's **Access Token Type** to be **JWT (self-contained)**. - -```csharp -using Modgud.Client.AspNetCore; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = "https://auth.example.com"; - options.Audience = "event-tree-api"; // matches an OAuthApi in the IdP - }); - -builder.Services.AddModgudClient(o => -{ - o.Authority = "https://auth.example.com"; - o.Audience = "event-tree-api"; // same value as above -}); - -var app = builder.Build(); - -app.UseAuthentication(); -app.UseAuthorization(); - -// Role-gated — uses the standard [Authorize] attribute since -// roles are projected to ClaimTypes.Role. -app.MapGet("/admin/ping", () => "pong") - .RequireAuthorization(p => p.RequireRole("Editor")); - -// Permission-gated — bare 2-segment string. The IdP has already -// expanded realm:admin / :admin to catalog entries, so -// this is a pure contains-check. -app.MapPost("/calendars/{id}", (string id) => Results.Ok()) - .RequiresModgudPermission("calendar:write"); - -app.Run(); -``` - -## Quickstart — reference (opaque) access tokens - -Works with Modgud's **default** token format — no need to switch the client -to JWT. The endpoint gates (`RequireRole`, `RequiresModgudPermission`) are -identical to the JWT quickstart; only the authentication registration differs: - -```csharp -using Modgud.Client.AspNetCore; - -builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = "https://auth.example.com"; - o.Audience = "event-tree-api"; // == the introspection client_id - o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - }); -``` - -**Setup requirement.** The resource server introspects with a confidential -OAuth client whose **`client_id` equals its `Audience`**. The IdP only -reveals a token — its `active` status and `resource_access` block — to a -caller that is one of the token's audiences (or its presenter); the audience -is the RS's own id (the RFC 8707 `resource=` value), so registering the -introspection client under that same id is what authorises it. Credentials -are sent as form-body parameters (`client_secret_post`), which also handles a -URL-shaped audience id that HTTP Basic can't (it splits on the scheme colon). - -## How the claims land on the principal - -The IdP emits permissions per audience in Keycloak shape: - -```json -"resource_access": { - "event-tree-api": { - "roles": ["Editor", "Viewer"], - "permissions": ["calendar:read", "calendar:write"] - } -} -``` - -`ModgudClaimsTransformation` projects that into flat claims: - -| Source field | Flat claim type | -| --- | --- | -| `roles` | `ClaimTypes.Role` | -| `permissions` | `"permission"` | - -> Groups are deliberately **not** emitted by the IdP (hub boundary): group -> membership is IdP-internal and is expanded into roles/permissions before -> emission. The `GroupClaimType` constant and the old `groups` flattener are -> retained only for binary compatibility and are `[Obsolete]`. - -Read them with standard claims APIs: - -```csharp -var perms = ctx.User.FindAll("permission").Select(c => c.Value); -``` - -## Configuration reference - -`AddModgudClient` (JWT mode) — `ModgudOptions`: - -| Option | Description | -| --- | --- | -| `Authority` | IdP base URL. Used to fetch `{Authority}/connect/userinfo`. Same value as `JwtBearerOptions.Authority`. | -| `Audience` | The audience this resource server identifies as — same value as `JwtBearerOptions.Audience`. Looked up against `resource_access[…]`. | -| `JwtBearerScheme` | Scheme name to attach to. Defaults to `"Bearer"`. | - -`AddModgudReferenceTokenClient` (introspection mode) — `ModgudReferenceTokenOptions`: - -| Option | Description | -| --- | --- | -| `Authority` | IdP base URL. Used to build `{Authority}/connect/introspect`. | -| `Audience` | The RS's audience — the `resource_access[…]` key, and the default introspection `client_id`. | -| `IntrospectionClientSecret` | Secret for the introspection client. Required. | -| `IntrospectionClientId` | Overrides the introspection `client_id`. Defaults to `Audience`. | - -## License - -Apache-2.0. See [LICENSE](https://github.com/cocoar-dev/modgud/blob/develop/LICENSE). diff --git a/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs b/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs deleted file mode 100644 index b64ebfa1..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/RequiresModgudPermissionFilter.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Modgud.Client.AspNetCore; - -/// -/// Endpoint filter that gates a Minimal-API endpoint on a Modgud -/// permission. Reads the "permission" claims that -/// stamped on the principal -/// (flattened from resource_access[].permissions) -/// and does a pure contains-check against the requested string. -/// -/// The IdP already pre-expanded bypass tiers (realm:admin → -/// every catalog string of every reachable App; <r>:admin → -/// every <r>:<a> in the App's catalog) before emission, so -/// no PermissionEvaluator dance is needed here — the filter is a -/// straight membership test. -/// -/// Synchronous (no I/O). Returns 401 when anonymous, -/// 403 when authenticated but lacking the permission. -/// -public sealed class RequiresModgudPermissionFilter : IEndpointFilter -{ - private readonly string _permission; - - public RequiresModgudPermissionFilter(string permission) - { - ArgumentException.ThrowIfNullOrEmpty(permission); - _permission = permission; - } - - public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var user = context.HttpContext.User; - if (user.Identity?.IsAuthenticated != true) - return ValueTask.FromResult(Results.Unauthorized()); - - var hasPermission = user - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Any(c => string.Equals(c.Value, _permission, StringComparison.Ordinal)); - - if (!hasPermission) - return ValueTask.FromResult(Results.Forbid()); - - return next(context); - } -} - -public static class RequiresModgudPermissionExtensions -{ - /// - /// Gates the route group on the given permission. Equivalent to wiring - /// as an endpoint filter. - /// The permission is bare 2-segment ("<resource>:<action>") — - /// the App context is implicit from the audience the lib was configured - /// with. - /// - public static RouteGroupBuilder RequiresModgudPermission(this RouteGroupBuilder builder, string permission) - { - builder.AddEndpointFilter(new RequiresModgudPermissionFilter(permission)); - return builder; - } - - /// Per-endpoint variant of . - public static RouteHandlerBuilder RequiresModgudPermission(this RouteHandlerBuilder builder, string permission) - { - builder.AddEndpointFilter(new RequiresModgudPermissionFilter(permission)); - return builder; - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs b/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs deleted file mode 100644 index d927adc4..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -public static class ServiceCollectionExtensions -{ - /// - /// Wires the Modgud resource-server integration into a host: - /// - /// A JwtBearerEvents.OnTokenValidated handler that - /// fetches {Authority}/connect/userinfo with the user's - /// bearer token and merges the resource_access claim onto - /// the principal. - /// The pre-request - /// that flattens resource_access[] - /// into native , - /// "permission" and "group" claims. - /// The endpoint - /// filter (consumed via the RequiresModgudPermission - /// extension). - /// - /// - /// The IdP pre-expands bypass tiers (realm:admin, - /// <r>:admin) before emission, so the lib doesn't need - /// any evaluator logic — exact-match against the "permission" - /// claims is sufficient. - /// - /// Typical usage in a resource-server Program.cs: - /// - /// services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - /// .AddJwtBearer(options => - /// { - /// options.Authority = "https://auth.example.com"; - /// options.Audience = "https://policy-api.cocoar.dev"; - /// }); - /// - /// services.AddModgudClient(o => - /// { - /// o.Authority = "https://auth.example.com"; - /// o.Audience = "https://policy-api.cocoar.dev"; - /// }); - /// - /// // Now [Authorize(Roles = "Editor")] and - /// // .RequiresModgudPermission("policy:write") just work. - /// - /// - public static IServiceCollection AddModgudClient( - this IServiceCollection services, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - services.Configure(configure); - services.AddTransient(); - - // Hook UserInfo-fetching into the JwtBearer scheme — pure - // AddJwtBearer doesn't do that natively. Composable: any - // existing OnTokenValidated handler is preserved. - services.AddSingleton, ModgudJwtBearerPostConfigure>(); - - return services; - } - - /// - /// Registers the Modgud reference-token (introspection) authentication - /// scheme so a resource server can accept Modgud's default opaque access - /// tokens without switching its OAuth client to JWT. Each request validates - /// the bearer token via /connect/introspect (RFC 7662) and projects - /// the response — including the per-audience resource_access block — - /// onto the principal, where the shared - /// flattens it into role / permission claims. The - /// RequiresModgudPermission filter then works identically to the JWT - /// path. - /// - /// The resource server introspects with a confidential client whose - /// client_id equals its own - /// — see the options docs for why. Set the secret via - /// . - /// - /// Typical usage in a resource-server Program.cs: - /// - /// services.AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - /// .AddModgudReferenceTokenClient(o => - /// { - /// o.Authority = "https://auth.example.com"; - /// o.Audience = "https://mcp.acme.example"; // == introspection client_id - /// o.IntrospectionClientSecret = builder.Configuration["Modgud:IntrospectionSecret"]; - /// }); - /// - /// - public static AuthenticationBuilder AddModgudReferenceTokenClient( - this AuthenticationBuilder builder, - Action configure) - => builder.AddModgudReferenceTokenClient( - ModgudReferenceTokenDefaults.AuthenticationScheme, configure); - - /// - /// Scheme-named overload of - /// , - /// for hosts that register the introspection handler under a custom scheme - /// name (e.g. to run it alongside JwtBearer). - /// - public static AuthenticationBuilder AddModgudReferenceTokenClient( - this AuthenticationBuilder builder, - string authenticationScheme, - Action configure) - { - ArgumentNullException.ThrowIfNull(configure); - - builder.Services.AddTransient(); - - // The shared ModgudClaimsTransformation reads ModgudOptions.Audience to - // pick the resource_access[...] block, so mirror the audience there. - builder.Services.Configure(authenticationScheme, configure); - builder.Services.AddOptions().Configure>( - (modgud, refToken) => - { - var o = refToken.Get(authenticationScheme); - modgud.Authority = o.Authority; - modgud.Audience = o.Audience; - }); - - return builder.AddScheme( - authenticationScheme, configure); - } -} diff --git a/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs b/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs deleted file mode 100644 index d7a0d625..00000000 --- a/src/dotnet/Modgud.Client.AspNetCore/UserInfoEnricher.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Modgud.Client.AspNetCore; - -/// -/// Wires JwtBearerEvents.OnTokenValidated to make sure the validated -/// principal carries a resource_access claim, preferring the token's -/// own embedded claim and falling back to -/// {Authority}/connect/userinfo only when the token carries none. -/// -/// Preference order and why: since federation v1.1 the IdP bakes -/// resource_access straight into every access token at issuance — -/// /connect/userinfo merely echoes that same baked block back -/// verbatim (see UserInfoPerAudienceTests -/// .JwtClient_Bakes_ResourceAccess_Into_AccessToken_And_UserInfo_Echoes -/// on the IdP side). So when the JwtBearer-validated token already has the -/// claim, fetching UserInfo is a redundant round-trip that returns the exact -/// same data — this handler skips it. It only falls back to UserInfo for -/// tokens that don't carry the claim themselves (e.g. opaque/reference -/// access tokens the host validates via introspection instead of JWT -/// parsing, or older IdP versions). -/// -/// Pure AddJwtBearer only validates the token — it doesn't -/// fetch UserInfo on its own (that's an AddOpenIdConnect feature). -/// For resource servers that want the lib's claims-transformation to work -/// even when the token itself has no resource_access, the claim must -/// reach the principal somehow. This handler is the missing piece. -/// -/// Network fault tolerance: if UserInfo is unreachable or returns -/// a non-2xx, the handler logs and silently continues — the request -/// proceeds with whatever claims the bearer token already carried, and -/// downstream gates (RequiresModgudPermission, [Authorize(Roles=...)]) -/// will return 403 if those weren't enough. This is the security-positive -/// default: a transient IdP outage MUST NOT 500 the whole API. Note this -/// fail-open behaviour only applies to the fallback path — a token that -/// already carries the claim never touches the network at all. -/// -internal sealed class ModgudUserInfoEnricher -{ - // Settable (not just a readonly field) so unit tests can substitute a - // fake HttpMessageHandler and assert on call counts. Production callers - // never touch this — it defaults to a real HttpClient. - internal static HttpClient SharedClient { get; set; } = new(); - - public static async Task EnrichAsync(TokenValidatedContext context) - { - var logger = context.HttpContext.RequestServices - .GetRequiredService() - .CreateLogger("Modgud.UserInfoEnricher"); - - // Preference order: the validated token's own resource_access claim - // wins. /connect/userinfo only ever echoes the same baked block, so - // if it's already on the principal there is nothing UserInfo could - // add — skip the round-trip entirely. - if (context.Principal?.Identity is ClaimsIdentity validatedIdentity && - !string.IsNullOrEmpty(validatedIdentity - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)?.Value)) - { - logger.LogDebug( - "Modgud: access token already carries a resource_access claim; " + - "skipping the /connect/userinfo round-trip."); - return; - } - - var options = context.HttpContext.RequestServices - .GetRequiredService>().Value; - - // Token was just validated → the bearer string is on the request. - var rawAuth = context.HttpContext.Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(rawAuth) || - !AuthenticationHeaderValue.TryParse(rawAuth, out var header) || - !string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrEmpty(header.Parameter)) - { - return; - } - - var url = options.Authority.TrimEnd('/') + "/connect/userinfo"; - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", header.Parameter); - - try - { - using var response = await SharedClient.SendAsync(request, - context.HttpContext.RequestAborted); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug( - "Modgud: UserInfo fetch returned {Status}; continuing without enrichment.", - (int)response.StatusCode); - return; - } - - var body = await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted); - using var json = JsonDocument.Parse(body); - if (!json.RootElement.TryGetProperty("resource_access", out var resourceAccess) || - resourceAccess.ValueKind != JsonValueKind.Object) - { - return; - } - - // Add as a string-typed claim — the ClaimsTransformation will - // parse the JSON and project the configured-audience block. - if (context.Principal?.Identity is ClaimsIdentity identity) - { - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, - resourceAccess.GetRawText())); - } - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) - { - logger.LogWarning(ex, - "Modgud: UserInfo fetch failed; continuing without enrichment. " + - "Downstream gates will use whatever the bearer token carried."); - } - } -} - -/// -/// Hooks the lib's JwtBearer behaviour in via PostConfigure. Two composable -/// event handlers, each preserving any handler the host already set: -/// -/// OnMessageReceived — lifts a DPoP-bound token out of the -/// Authorization: DPoP … header so JwtBearer validates the JWT it -/// carries (RFC 9449, #118); a plain Bearer request is untouched. -/// OnTokenValidated — enforces the DPoP cnf.jkt binding -/// (), then, if the token was -/// accepted, ensures the principal carries resource_access -/// (). -/// -/// -internal sealed class ModgudJwtBearerPostConfigure : IPostConfigureOptions -{ - private readonly ModgudOptions _options; - - public ModgudJwtBearerPostConfigure(IOptions options) - { - _options = options.Value; - } - - public void PostConfigure(string? name, JwtBearerOptions options) - { - if (!string.Equals(name, _options.JwtBearerScheme, StringComparison.Ordinal)) return; - - if (string.IsNullOrWhiteSpace(_options.Authority)) - throw new InvalidOperationException( - "ModgudOptions.Authority must be set to the IdP base URL " + - "(e.g. \"https://auth.example.com\") so the lib can fetch /connect/userinfo. " + - "Configure it via AddModgudClient."); - - options.Events ??= new JwtBearerEvents(); - - // Accept a DPoP-scheme JWT: JwtBearer only reads `Bearer`, so lift the - // token out of the `DPoP` header for it. Only sets the token when the - // host hasn't already resolved one and the scheme is DPoP. - var existingReceived = options.Events.OnMessageReceived; - options.Events.OnMessageReceived = async ctx => - { - if (existingReceived is not null) await existingReceived(ctx); - if (string.IsNullOrEmpty(ctx.Token) && - ModgudDpopJwtBearer.ExtractDpopSchemeToken(ctx.HttpContext.Request) is { } dpopToken) - { - ctx.Token = dpopToken; - } - }; - - var existingValidated = options.Events.OnTokenValidated; - options.Events.OnTokenValidated = async ctx => - { - if (existingValidated is not null) await existingValidated(ctx); - - // Enforce the DPoP binding BEFORE enrichment: a bound token presented - // wrong (as bearer, or with an invalid/mismatched proof) must be - // rejected regardless of its claims, and there's no point fetching - // UserInfo for a request we're about to fail. - ModgudDpopJwtBearer.EnforceBinding(ctx); - if (ctx.Result is null) - await ModgudUserInfoEnricher.EnrichAsync(ctx); - }; - } -} diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs index 4fcc2965..5d750b58 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/DpopProofValidator.cs @@ -22,7 +22,7 @@ namespace Modgud.Infrastructure.OpenIddict.Dpop; /// jti replay detection is left to the caller (it needs a per-realm store /// and a TTL policy that live outside this crypto core). Keeping it side-effect /// free is what lets the identical file be duplicated into the dependency-light -/// Modgud.Client.AspNetCore NuGet for the resource-server side. +/// Modgud.AspNetCore.ResourceServer NuGet for the resource-server side. /// /// /// diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs index 201eeaea..db30b63c 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/Dpop/JwkThumbprint.cs @@ -30,7 +30,7 @@ namespace Modgud.Infrastructure.OpenIddict.Dpop; /// /// Kept dependency-free (BCL only: + /// ) so the exact same file can be -/// duplicated verbatim into the dependency-light Modgud.Client.AspNetCore +/// duplicated verbatim into the dependency-light Modgud.AspNetCore.ResourceServer /// NuGet for resource-server-side validation. Any change here MUST be mirrored /// there — see the "keep in sync" note on the client copy. /// diff --git a/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj b/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj index 001ae982..ca2c9db4 100644 --- a/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj +++ b/src/dotnet/Modgud.Permissions.Abstractions/Modgud.Permissions.Abstractions.csproj @@ -3,7 +3,7 @@ Modgud.Permissions Modgud.Permissions.Abstractions - Pure permission-evaluation primitives shared by the IdP server-side and external resource-server libs. No persistence / web / DI deps — reuse without transitive bloat. + Pure permission-evaluation primitives for the IdP and consumers that evaluate raw grants. No persistence / web / DI deps — reuse without transitive bloat. diff --git a/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs b/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs index 4da126cd..d5c2ed11 100644 --- a/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs +++ b/src/dotnet/Modgud.Permissions.Abstractions/PermissionEvaluator.cs @@ -1,11 +1,11 @@ namespace Modgud.Permissions; /// -/// Pure permission-check logic with no I/O dependencies — the same evaluator -/// is used IdP-side (by PermissionService in the Authorization slice) -/// and RS-side (by the Modgud.Client.AspNetCore helper lib). -/// Lives in Modgud.Permissions.Abstractions so external resource -/// servers can reuse it without pulling in Marten/Wolverine/JsEval. +/// Pure permission-check logic with no I/O dependencies. It is used IdP-side +/// by PermissionService in the Authorization slice and remains available +/// to consumers that need to evaluate raw grants without pulling in +/// Marten/Wolverine/JsEval. The resource-server package does not use it: +/// distributed permissions are pre-expanded by the IdP and checked exactly. /// /// Permission strings within an App are 2-segment /// "<resource>:<action>". The App context is implicit from the diff --git a/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs b/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs index ddc6a5de..86b96f1a 100644 --- a/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Architecture/PermissionsAbstractionsPurityTests.cs @@ -3,13 +3,12 @@ namespace Modgud.Tests.Unit.Architecture; /// -/// Modgud.Permissions.Abstractions is the one assembly external -/// resource-server consumers (via Modgud.Client.AspNetCore) link -/// against to evaluate permissions in-process. Its whole reason to exist is -/// the absence of IdP-side baggage — Marten, Wolverine, JsEval, ASP.NET -/// hosting, anything Modgud-internal. If any of those leak in, the -/// abstraction stops being reusable and downstream services drag in the -/// kitchen sink. +/// Modgud.Permissions.Abstractions is the reusable assembly for +/// consumers that evaluate raw grants in-process. Its whole reason to exist +/// is the absence of IdP-side baggage — Marten, Wolverine, JsEval, ASP.NET +/// hosting, anything Modgud-internal. The resource-server package deliberately +/// does exact checks against IdP-pre-expanded claims and does not use this +/// evaluator. /// public class PermissionsAbstractionsPurityTests { @@ -80,7 +79,7 @@ public void PermissionsAbstractions_should_not_depend_on_AspNetCore() result.IsSuccessful, TestResultFormatter.Format(result, "Modgud.Permissions.Abstractions must not depend on ASP.NET Core — " + - "the ASP.NET-aware integration helpers live in Modgud.Client.AspNetCore.")); + "the ASP.NET-aware integration helpers live in Modgud.AspNetCore.ResourceServer.")); } [Fact] @@ -95,7 +94,7 @@ public void PermissionsAbstractions_should_not_depend_on_other_Modgud_internals( "Modgud.Authorization", "Modgud.Infrastructure", "Modgud.Api", - "Modgud.Client.AspNetCore") + "Modgud.AspNetCore.ResourceServer") .GetResult(); Assert.True( diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs deleted file mode 100644 index 90a30d7c..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/IntrospectionHandlerTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System.Net; -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins — the reference-token -/// validation path (#132). Introspection is the validation here, so -/// the contract is fail-closed: only an active, audience-valid token -/// yields a principal; everything else (inactive, non-2xx, transport error, -/// foreign audience, malformed body) rejects. -/// -/// The IdP-side companion pin -/// (UserInfoPerAudienceTests.Introspection_Carries_ResourceAccess_Only_For_Audience_Or_Presenter_Client) -/// proves the real endpoint returns resource_access to an audience -/// client; these tests pin how the lib projects that response. -/// -public class IntrospectionHandlerTests -{ - private const string Authority = "https://auth.example.com"; - private const string Audience = "https://mcp.acme.example"; - - private sealed class StubHttpMessageHandler( - Func? respond = null) : HttpMessageHandler - { - public int CallCount { get; private set; } - public string? LastRequestBody { get; private set; } - public Uri? LastRequestUri { get; private set; } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - CallCount++; - LastRequestUri = request.RequestUri; - if (request.Content is not null) - LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); - if (respond is null) - throw new InvalidOperationException( - "Modgud introspection made an HTTP call the test did not expect."); - return respond(request); - } - } - - private static ModgudReferenceTokenOptions Options( - string audience = Audience, string? clientId = null, string secret = "rs-secret") - => new() - { - Authority = Authority, - Audience = audience, - IntrospectionClientId = clientId, - IntrospectionClientSecret = secret, - }; - - private static async Task<(ClaimsPrincipal? Principal, StubHttpMessageHandler Handler)> IntrospectAsync( - ModgudReferenceTokenOptions options, StubHttpMessageHandler handler, string token = "opaque-ref-token") - { - var original = ModgudTokenIntrospection.SharedClient; - ModgudTokenIntrospection.SharedClient = new HttpClient(handler); - try - { - var principal = await ModgudTokenIntrospection.IntrospectAsync( - options, token, "ModgudIntrospection", NullLogger.Instance, CancellationToken.None); - return (principal, handler); - } - finally - { - ModgudTokenIntrospection.SharedClient = original; - } - } - - private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) - => new(status) { Content = new StringContent(body) }; - - [Fact] - public async Task Active_token_yields_principal_with_resource_access_and_standard_claims() - { - var body = """{"active":true,"sub":"u1","name":"Alice","scope":"openid permissions","aud":["https://mcp.acme.example","some-client"],"resource_access":{"https://mcp.acme.example":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - - Assert.NotNull(principal); - var identity = (ClaimsIdentity)principal!.Identity!; - Assert.True(identity.IsAuthenticated); - Assert.Equal("u1", identity.FindFirst("sub")?.Value); - Assert.Equal("u1", identity.FindFirst(ClaimTypes.NameIdentifier)?.Value); - Assert.Equal("Alice", identity.Name); // nameType "name" - // The load-bearing claim survives verbatim for the transformation. - var rawResourceAccess = identity.FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)?.Value ?? ""; - Assert.Contains("""{"permissions":["policy:write"],"roles":["Editor"]}""", rawResourceAccess); - } - - [Fact] - public async Task Resource_access_flows_through_the_shared_claims_transformation() - { - var body = """{"active":true,"sub":"u1","aud":"https://mcp.acme.example","resource_access":{"https://mcp.acme.example":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - - var transform = new ModgudClaimsTransformation(Microsoft.Extensions.Options.Options.Create( - new ModgudOptions { Authority = Authority, Audience = Audience })); - var transformed = await transform.TransformAsync(principal!); - - Assert.Contains(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType), - c => c.Value == "policy:write"); - Assert.Contains(transformed.FindAll(ClaimTypes.Role), c => c.Value == "Editor"); - } - - [Fact] - public async Task Introspection_request_uses_form_body_client_credentials() - { - var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; - var (_, handler) = await IntrospectAsync( - Options(secret: "rs-secret"), new StubHttpMessageHandler(_ => Json(body)), token: "the-token"); - - Assert.Equal($"{Authority}/connect/introspect", handler.LastRequestUri!.ToString()); - var form = handler.LastRequestBody!; - Assert.Contains("token=the-token", form); - Assert.Contains($"client_id={Uri.EscapeDataString(Audience)}", form); - Assert.Contains("client_secret=rs-secret", form); - } - - [Fact] - public async Task Client_id_defaults_to_audience_but_can_be_overridden() - { - var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; - var (_, handler) = await IntrospectAsync( - Options(clientId: "custom-introspector"), new StubHttpMessageHandler(_ => Json(body))); - - Assert.Contains("client_id=custom-introspector", handler.LastRequestBody!); - } - - [Fact] - public async Task Inactive_token_is_rejected() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => Json("""{"active":false}"""))); - Assert.Null(principal); - } - - [Fact] - public async Task Active_token_for_a_different_audience_is_rejected() - { - // active:true but the token isn't for us — defence in depth against a - // misconfigured introspection client id. - var body = """{"active":true,"aud":["https://other-rs.example.com"],"resource_access":{"https://other-rs.example.com":{"permissions":["policy:write"]}}}"""; - var (principal, _) = await IntrospectAsync(Options(), new StubHttpMessageHandler(_ => Json(body))); - Assert.Null(principal); - } - - [Fact] - public async Task Non_success_status_is_rejected_fail_closed() - { - var (principal, handler) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized))); - Assert.Null(principal); - Assert.Equal(1, handler.CallCount); - } - - [Fact] - public async Task Transport_failure_is_rejected_fail_closed() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"))); - Assert.Null(principal); - } - - [Fact] - public async Task Malformed_json_is_rejected() - { - var (principal, _) = await IntrospectAsync( - Options(), new StubHttpMessageHandler(_ => Json("not json"))); - Assert.Null(principal); - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs deleted file mode 100644 index b0430bc1..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/ModgudClaimsTransformationTests.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins the resource-server-side claims-transformation: it reads the -/// resource_access claim that the JWT-bearer middleware populated -/// (from the JWT itself or via UserInfo) and projects the configured -/// audience's block onto the principal as flat ClaimTypes.Role / -/// "permission" claims. Groups are NEVER flattened — the IdP never emits -/// a groups block (hub boundary, federation v1). -/// -/// The IdP pre-expands bypass tiers, so the lib is a pure -/// claims-flattener — no HTTP, no cache, no evaluator. -/// -public class ModgudClaimsTransformationTests -{ - private const string Audience = "https://policy-api.cocoar.dev"; - - private static ModgudClaimsTransformation NewSubject(string audience = Audience) => - new(Options.Create(new ModgudOptions { Audience = audience })); - - private static ClaimsPrincipal NewAuthenticatedPrincipal(params Claim[] claims) - { - var identity = new ClaimsIdentity(claims, authenticationType: "test"); - return new ClaimsPrincipal(identity); - } - - private static Claim ResourceAccessClaim(string raw) => - new(ModgudClaimsTransformation.ResourceAccessClaimType, raw); - - public class Roles - { - [Fact] - public async Task Flattens_audience_block_roles_into_ClaimTypes_Role() - { - // Standard happy path — UserInfo emitted a per-audience block - // and we have the matching audience configured. - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": [], - "roles": ["Editor", "Viewer"], - "groups": [] - }, - "https://other-api.example.com": { - "roles": ["ShouldNotLeak"] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var roles = transformed.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); - Assert.Contains("Editor", roles); - Assert.Contains("Viewer", roles); - Assert.DoesNotContain("ShouldNotLeak", roles); - } - - [Fact] - public async Task Other_audiences_in_resource_access_do_not_leak() - { - // Defence-in-depth: only OUR audience block contributes. - var resourceAccess = """{ "https://other-api.example.com": { "roles": ["Admin"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ClaimTypes.Role)); - } - - [Fact] - public async Task Idempotent_double_run_does_not_duplicate_roles() - { - // ClaimsTransformation runs more than once per pipeline pass. - var resourceAccess = $$"""{ "{{Audience}}": { "roles": ["Editor"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - var subject = NewSubject(); - - await subject.TransformAsync(principal); - await subject.TransformAsync(principal); - - Assert.Single(principal.FindAll(ClaimTypes.Role)); - } - } - - public class Permissions - { - [Fact] - public async Task Flattens_audience_block_permissions_into_permission_claims() - { - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": ["policy:read", "policy:write"], - "roles": [], - "groups": [] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var permissions = transformed - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Contains("policy:read", permissions); - Assert.Contains("policy:write", permissions); - } - } - - public class Groups - { - [Fact] - public async Task Groups_block_is_never_flattened_hub_boundary() - { - // Federation v1 hub boundary: the Modgud IdP never emits a "groups" - // block in resource_access (membership is IdP-internal, expanded into - // roles/permissions before emission). Even if some upstream put one - // there, the transformer must NOT surface "group" claims. - var resourceAccess = $$""" - { - "{{Audience}}": { - "permissions": [], - "roles": [], - "groups": [ - { "id": "g-1", "name": "DevOps" }, - { "id": "g-2", "name": "Mitarbeiter" } - ] - } - } - """; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - // "group" is the quarantined GroupClaimType value — assert via the - // literal so the test itself doesn't reference the [Obsolete] symbol. - Assert.Empty(transformed.FindAll("group")); - } - } - - public class ShortCircuits - { - [Fact] - public async Task Anonymous_principal_is_left_untouched() - { - var anon = new ClaimsPrincipal(new ClaimsIdentity()); - - var transformed = await NewSubject().TransformAsync(anon); - - Assert.Empty(transformed.FindAll(ClaimTypes.Role)); - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Missing_resource_access_claim_is_a_no_op() - { - // Pure-auth tokens (no roles scope, etc.) won't have it. Bail - // gracefully rather than throwing. - var principal = NewAuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, "user-1")); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Malformed_resource_access_json_is_ignored() - { - // Don't throw mid-request — that would 500 every endpoint - // for a cosmetic IDP misconfiguration. - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim("this is not json")); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - - [Fact] - public async Task Configured_audience_not_in_resource_access_is_a_no_op() - { - // A token whose aud[] doesn't include this RS still authenticated - // (signature/issuer valid). It just doesn't grant any of OUR - // permissions — caller's [Authorize] / RequiresModgudPermission - // will then return 403 cleanly. - var resourceAccess = """{ "https://other-api.example.com": { "permissions": ["policy:read"] } }"""; - var principal = NewAuthenticatedPrincipal(ResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - Assert.Empty(transformed.FindAll(ModgudClaimsTransformation.PermissionClaimType)); - } - } - - public class Configuration - { - [Fact] - public void Constructor_throws_when_Audience_is_missing() - { - var ex = Assert.Throws(() => - new ModgudClaimsTransformation(Options.Create(new ModgudOptions { Audience = "" }))); - Assert.Contains("Audience", ex.Message); - } - } - - /// - /// Issue #116 (Option A): since the access token now carries - /// resource_access itself, the transformer must flatten it - /// identically regardless of which path put the claim on the identity. - /// ASP.NET Core's JwtBearer (JsonWebTokenHandler) maps a JSON-object JWT - /// payload property to a claim whose Value is the raw JSON text - /// and whose ValueType is — - /// confirmed empirically (CreateToken + ValidateTokenAsync round-trip) - /// rather than assumed. These tests source the claim that way instead of - /// the enricher's plain-string shape and expect the exact same output as - /// the mirrored / tests - /// above — the transformer only ever reads , so - /// ValueType must be irrelevant to it. - /// - public class TokenEmbeddedClaimShape - { - private static Claim TokenMappedResourceAccessClaim(string raw) => - new(ModgudClaimsTransformation.ResourceAccessClaimType, raw, JsonClaimValueTypes.Json); - - [Fact] - public async Task Flattens_audience_block_roles_identically_to_the_userinfo_shaped_claim() - { - var resourceAccess = $$"""{ "{{Audience}}": { "roles": ["Editor", "Viewer"] } }"""; - var principal = NewAuthenticatedPrincipal(TokenMappedResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var roles = transformed.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); - Assert.Contains("Editor", roles); - Assert.Contains("Viewer", roles); - } - - [Fact] - public async Task Flattens_audience_block_permissions_identically_to_the_userinfo_shaped_claim() - { - var resourceAccess = $$"""{ "{{Audience}}": { "permissions": ["policy:read", "policy:write"] } }"""; - var principal = NewAuthenticatedPrincipal(TokenMappedResourceAccessClaim(resourceAccess)); - - var transformed = await NewSubject().TransformAsync(principal); - - var permissions = transformed - .FindAll(ModgudClaimsTransformation.PermissionClaimType) - .Select(c => c.Value) - .ToList(); - Assert.Contains("policy:read", permissions); - Assert.Contains("policy:write", permissions); - } - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs deleted file mode 100644 index 3a5602fe..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/RequiresModgudPermissionFilterTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins the resource-server-side endpoint filter. The filter reads the -/// "permission" claims that -/// stamped on the principal -/// (flattened from resource_access[].permissions) and -/// does pure exact-match against the requested string. -/// -/// The IdP pre-expanded bypass tiers (realm:admin, -/// <r>:admin) before emission, so the filter does NOT know -/// about admin bypasses — they're already represented as concrete -/// permissions in the claim set. A user with policy:admin -/// upstream sees policy:read, policy:write, ... materialised -/// in the principal claims; the filter just checks membership. -/// -public class RequiresModgudPermissionFilterTests -{ - private static EndpointFilterInvocationContext NewContext( - ClaimsPrincipal? user = null) - { - var http = new DefaultHttpContext(); - if (user is not null) http.User = user; - return new DefaultEndpointFilterInvocationContext(http); - } - - private static ClaimsPrincipal NewPrincipalWithPermissions(params string[] permissions) - { - var identity = new ClaimsIdentity("test"); - foreach (var p in permissions) - identity.AddClaim(new Claim(ModgudClaimsTransformation.PermissionClaimType, p)); - return new ClaimsPrincipal(identity); - } - - private sealed class CapturingNext - { - public bool Called { get; private set; } - public ValueTask InvokeAsync(EndpointFilterInvocationContext _) - { - Called = true; - return ValueTask.FromResult(Results.Ok("inner-handler-result")); - } - } - - [Fact] - public async Task Anonymous_principal_returns_401() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var anon = new ClaimsPrincipal(new ClaimsIdentity()); - var ctx = NewContext(anon); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Exact_permission_match_passes_to_next() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:write")); - var next = new CapturingNext(); - - await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.True(next.Called); - } - - [Fact] - public async Task Pre_expanded_admin_bypass_passes_to_next() - { - // The IdP already expanded policy:admin upstream into policy:read, - // policy:write, policy:admin (every : in the catalog) before - // putting them in resource_access. From the filter's perspective - // it's just exact-match against the materialised list. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions( - "policy:read", "policy:write", "policy:admin")); - var next = new CapturingNext(); - - await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.True(next.Called); - } - - [Fact] - public async Task Lone_admin_marker_does_not_grant_other_actions() - { - // If the principal somehow only has the bare "policy:admin" claim - // (e.g. because the IdP didn't pre-expand, or the test didn't - // simulate it), the filter does NOT bypass — exact-match only. - // This pins that the filter doesn't accidentally implement - // bypass semantics on top of an already-expanded source. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:admin")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Different_resource_does_not_leak() - { - // Holding knowledge:write must NOT cover policy:write. - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("knowledge:write")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Empty_permission_set_returns_403() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions()); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public async Task Wrong_action_on_correct_resource_returns_403() - { - var filter = new RequiresModgudPermissionFilter("policy:write"); - var ctx = NewContext(NewPrincipalWithPermissions("policy:read")); - var next = new CapturingNext(); - - var result = await filter.InvokeAsync(ctx, next.InvokeAsync); - - Assert.False(next.Called); - Assert.IsAssignableFrom(result); - } - - [Fact] - public void Constructor_rejects_empty_permission_string() - { - Assert.Throws(() => new RequiresModgudPermissionFilter("")); - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs b/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs deleted file mode 100644 index c745bbee..00000000 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/UserInfoEnricherTests.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; - -namespace Modgud.Tests.Unit.Client.AspNetCore; - -/// -/// Pins 's preference order (issue #116, -/// Option A): the access token's own embedded resource_access claim -/// wins — /connect/userinfo is called ONLY as a fallback when the -/// validated token carries none. -/// -/// Why this is safe: since federation v1.1 the IdP bakes -/// resource_access into every access token at issuance, and -/// /connect/userinfo merely echoes that same block back verbatim -/// (IdP-side UserInfoPerAudienceTests -/// .JwtClient_Bakes_ResourceAccess_Into_AccessToken_And_UserInfo_Echoes -/// pins the echo). Preferring the token claim therefore changes freshness -/// in no way — it only removes a redundant per-request HTTP round-trip for -/// tokens that already carry the claim. -/// -/// The JWT-mapped claim shape is reproduced faithfully here (claim -/// type "resource_access", raw-JSON-text Value, -/// ValueType) — see the -/// ModgudClaimsTransformation doc remarks for how that shape was -/// established empirically. -/// -public class UserInfoEnricherTests -{ - private const string Authority = "https://auth.example.com"; - - /// - /// Counts invocations and, unless a canned responder is supplied, - /// throws on any call — so an unexpected HTTP attempt fails the test - /// loudly instead of silently succeeding against a stub. - /// - private sealed class StubHttpMessageHandler( - Func? respond = null) : HttpMessageHandler - { - public int CallCount { get; private set; } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - CallCount++; - if (respond is null) - throw new InvalidOperationException( - "Modgud.UserInfoEnricher made an HTTP call the test did not expect."); - return Task.FromResult(respond(request)); - } - } - - private static IServiceProvider NewServices(string authority = Authority) - { - var services = new ServiceCollection(); - services.AddSingleton(NullLoggerFactory.Instance); - services.AddSingleton>( - Options.Create(new ModgudOptions { Authority = authority, Audience = "aud" })); - return services.BuildServiceProvider(); - } - - private static TokenValidatedContext NewContext( - IServiceProvider services, ClaimsPrincipal principal, string? bearerToken = "raw-access-token") - { - var httpContext = new DefaultHttpContext { RequestServices = services }; - if (bearerToken is not null) - httpContext.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken).ToString(); - - var scheme = new AuthenticationScheme( - JwtBearerDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme, typeof(JwtBearerHandler)); - return new TokenValidatedContext(httpContext, scheme, new JwtBearerOptions()) - { - Principal = principal, - }; - } - - /// - /// Mirrors exactly how ASP.NET Core's JwtBearer (JsonWebTokenHandler) - /// maps a JSON-object JWT payload property onto the validated - /// principal: claim type = the payload key verbatim, Value = raw JSON - /// text, ValueType = . - /// - private static ClaimsPrincipal PrincipalWithTokenEmbeddedResourceAccess(string rawJson) - { - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim( - ModgudClaimsTransformation.ResourceAccessClaimType, rawJson, - JsonClaimValueTypes.Json)); - return new ClaimsPrincipal(identity); - } - - private static ClaimsPrincipal PrincipalWithoutResourceAccess() - { - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "user-1")); - return new ClaimsPrincipal(identity); - } - - // Both nested classes mutate the shared static HttpClient seam - // (ModgudUserInfoEnricher.SharedClient) for the duration of each test. - // xUnit parallelizes across different test classes by default, so both - // are pinned to the same collection to force sequential execution - // against each other and avoid racing on that shared mutable state. - [Collection(nameof(UserInfoEnricherTests))] - public class PrefersTokenClaim - { - [Fact] - public async Task Token_embedded_resource_access_skips_userinfo_round_trip() - { - const string rawJson = """{"aud":{"roles":["Editor"],"permissions":["policy:write"]}}"""; - var principal = PrincipalWithTokenEmbeddedResourceAccess(rawJson); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(); // throws if invoked - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(0, handler.CallCount); - // The token's own claim is left untouched — no duplicate added. - Assert.Single(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindAll(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - - [Fact] - public async Task Empty_string_resource_access_claim_is_treated_as_absent() - { - // Defence-in-depth: a claim that technically exists but carries - // no data must NOT short-circuit the fallback — there'd be - // nothing for the transformation to read. - var identity = new ClaimsIdentity(authenticationType: "AuthenticationTypes.Federation"); - identity.AddClaim(new Claim(ModgudClaimsTransformation.ResourceAccessClaimType, "")); - var principal = new ClaimsPrincipal(identity); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"resource_access":{"aud":{"roles":[]}}}"""), - }); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - } - } - - [Collection(nameof(UserInfoEnricherTests))] - public class FallsBackToUserInfo - { - [Fact] - public async Task No_token_claim_fetches_userinfo_and_merges_resource_access() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - const string resourceAccessJson = """{"aud":{"roles":["Viewer"],"permissions":["policy:read"]}}"""; - var handler = new StubHttpMessageHandler(req => - { - Assert.Equal($"{Authority}/connect/userinfo", req.RequestUri!.ToString()); - Assert.Equal("Bearer", req.Headers.Authorization?.Scheme); - Assert.Equal("raw-access-token", req.Headers.Authorization?.Parameter); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent($$"""{"sub":"u1","resource_access":{{resourceAccessJson}}}"""), - }; - }); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - var claim = ((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType); - Assert.NotNull(claim); - Assert.Equal(resourceAccessJson, claim!.Value); - } - - [Fact] - public async Task No_token_claim_and_no_bearer_header_makes_no_http_call() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal, bearerToken: null); - - var handler = new StubHttpMessageHandler(); // throws if invoked - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(0, handler.CallCount); - } - - [Fact] - public async Task No_token_claim_transport_failure_fails_open() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler(_ => throw new HttpRequestException("boom")); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - // Must not throw — a transient IdP outage must not 500 the API. - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Null(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - - [Fact] - public async Task No_token_claim_non_success_status_fails_open() - { - var principal = PrincipalWithoutResourceAccess(); - var ctx = NewContext(NewServices(), principal); - - var handler = new StubHttpMessageHandler( - _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); - var original = ModgudUserInfoEnricher.SharedClient; - ModgudUserInfoEnricher.SharedClient = new HttpClient(handler); - try - { - await ModgudUserInfoEnricher.EnrichAsync(ctx); - } - finally - { - ModgudUserInfoEnricher.SharedClient = original; - } - - Assert.Equal(1, handler.CallCount); - Assert.Null(((ClaimsIdentity)ctx.Principal!.Identity!) - .FindFirst(ModgudClaimsTransformation.ResourceAccessClaimType)); - } - } -} diff --git a/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj b/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj index b1ae156b..7dc1cb18 100644 --- a/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj +++ b/src/dotnet/Modgud.Tests.Unit/Modgud.Tests.Unit.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs similarity index 98% rename from src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs rename to src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs index 6eb25ac5..634ca88c 100644 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopJwtBearerBindingTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopJwtBearerBindingTests.cs @@ -3,13 +3,13 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http; -using Modgud.Client.AspNetCore; +using Modgud.AspNetCore.ResourceServer; using Modgud.Tests.Unit.OAuth.Dpop; -namespace Modgud.Tests.Unit.Client.AspNetCore; +namespace Modgud.Tests.Unit.ResourceServer; /// -/// JWT-bearer path of DPoP in the client library (#118): lifting a DPoP-scheme +/// JWT-bearer path of DPoP in the resource-server package (#118): lifting a DPoP-scheme /// token into JwtBearer and enforcing the cnf.jkt binding on the validated /// principal (RFC 9449 §7.1) — the JWT twin of the introspection path covered by /// . diff --git a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs similarity index 95% rename from src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs rename to src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs index a50e2e5e..33e574db 100644 --- a/src/dotnet/Modgud.Tests.Unit/Client/AspNetCore/DpopResourceValidationTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/DpopResourceValidationTests.cs @@ -1,14 +1,14 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Modgud.Client.AspNetCore; -using Modgud.Client.AspNetCore.Dpop; +using Modgud.AspNetCore.ResourceServer; +using Modgud.AspNetCore.ResourceServer.Dpop; using Modgud.Tests.Unit.OAuth.Dpop; -namespace Modgud.Tests.Unit.Client.AspNetCore; +namespace Modgud.Tests.Unit.ResourceServer; /// -/// Resource-server side of DPoP in the client library: surfacing cnf.jkt +/// Resource-server side of DPoP in the published package: surfacing cnf.jkt /// from an introspection response and validating a request's proof against the /// bound key + the presented token (RFC 9449 §7.2). /// diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs new file mode 100644 index 00000000..f54925a5 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/IntrospectionHandlerTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class IntrospectionHandlerTests +{ + private const string Authority = "https://auth.example.com"; + private const string Audience = "https://mcp.example.com"; + + private sealed class StubHttpMessageHandler( + Func? respond = null) : HttpMessageHandler + { + public int CallCount { get; private set; } + public string? LastRequestBody { get; private set; } + public Uri? LastRequestUri { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallCount++; + LastRequestUri = request.RequestUri; + if (request.Content is not null) + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + if (respond is null) + throw new InvalidOperationException("Unexpected introspection call."); + return respond(request); + } + } + + private static ModgudIntrospectionOptions Options( + string audience = Audience, + string? clientId = null, + string secret = "rs-secret") + => new() + { + Authority = Authority, + Audience = audience, + ClientId = clientId ?? audience, + ClientSecret = secret, + }; + + private static async Task<(ClaimsPrincipal? Principal, StubHttpMessageHandler Handler)> IntrospectAsync( + ModgudIntrospectionOptions options, + StubHttpMessageHandler handler, + string token = "opaque-reference-token") + { + var principal = await ModgudTokenIntrospection.IntrospectAsync( + new HttpClient(handler), + options, + token, + "ModgudIntrospection", + NullLogger.Instance, + TestContext.Current.CancellationToken); + return (principal, handler); + } + + private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) + => new(status) { Content = new StringContent(body) }; + + [Fact] + public async Task Active_token_yields_audience_projected_principal() + { + var body = """{"active":true,"sub":"u1","name":"Alice","scope":"openid permissions","aud":["https://mcp.example.com","some-client"],"resource_access":{"https://mcp.example.com":{"permissions":["policy:write"],"roles":["Editor"]}}}"""; + + var (principal, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(body))); + + Assert.NotNull(principal); + Assert.Equal("u1", principal!.FindFirst(ClaimTypes.NameIdentifier)?.Value); + Assert.Equal("Alice", principal.Identity!.Name); + Assert.Contains(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "policy:write"); + Assert.Contains(principal.FindAll(ClaimTypes.Role), x => x.Value == "Editor"); + } + + [Fact] + public async Task Request_uses_form_body_credentials_and_audience_as_default_client_id() + { + var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; + + var (_, handler) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(body)), + token: "the-token"); + + Assert.Equal($"{Authority}/connect/introspect", handler.LastRequestUri!.ToString()); + Assert.Contains("token=the-token", handler.LastRequestBody!); + Assert.Contains($"client_id={Uri.EscapeDataString(Audience)}", handler.LastRequestBody!); + Assert.Contains("client_secret=rs-secret", handler.LastRequestBody!); + } + + [Fact] + public async Task Client_id_can_be_overridden() + { + var body = $$"""{"active":true,"aud":"{{Audience}}"}"""; + + var (_, handler) = await IntrospectAsync( + Options(clientId: "custom-introspector"), + new StubHttpMessageHandler(_ => Json(body))); + + Assert.Contains("client_id=custom-introspector", handler.LastRequestBody!); + } + + [Theory] + [InlineData("""{"active":false}""")] + [InlineData("""{"active":true,"aud":"another-api"}""")] + [InlineData("not-json")] + public async Task Inactive_foreign_or_malformed_tokens_are_rejected(string responseBody) + { + var (principal, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => Json(responseBody))); + + Assert.Null(principal); + } + + [Fact] + public async Task Non_success_and_transport_failures_are_rejected() + { + var (nonSuccess, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized))); + var (transportFailure, _) = await IntrospectAsync( + Options(), + new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"))); + + Assert.Null(nonSuccess); + Assert.Null(transportFailure); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs new file mode 100644 index 00000000..34fd3355 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudClaimsProjectorTests.cs @@ -0,0 +1,109 @@ +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ModgudClaimsProjectorTests +{ + private const string Audience = "https://policy-api.example.com"; + + private static ClaimsPrincipal Principal(string resourceAccess, bool authenticated = true) + { + var identity = new ClaimsIdentity( + authenticated ? [new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess, JsonClaimValueTypes.Json)] : [], + authenticated ? "test" : null); + if (!authenticated) + identity.AddClaim(new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess)); + return new ClaimsPrincipal(identity); + } + + [Fact] + public void Projects_only_the_selected_audience() + { + var principal = Principal($$""" + { + "{{Audience}}": { + "roles": ["Editor"], + "permissions": ["policy:read", "policy:write"] + }, + "https://other.example.com": { + "roles": ["ShouldNotLeak"], + "permissions": ["other:admin"] + } + } + """); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Contains(principal.FindAll(ClaimTypes.Role), x => x.Value == "Editor"); + Assert.DoesNotContain(principal.FindAll(ClaimTypes.Role), x => x.Value == "ShouldNotLeak"); + Assert.Contains(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "policy:write"); + Assert.DoesNotContain(principal.FindAll(ModgudClaimTypes.Permission), x => x.Value == "other:admin"); + } + + [Fact] + public void Different_schemes_can_project_different_audiences_without_global_state() + { + const string json = """ + { + "api-a": { "permissions": ["a:read"] }, + "api-b": { "permissions": ["b:read"] } + } + """; + var schemeA = Principal(json); + var schemeB = Principal(json); + + ModgudClaimsProjector.Project(schemeA, "api-a"); + ModgudClaimsProjector.Project(schemeB, "api-b"); + + Assert.Equal(["a:read"], schemeA.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + Assert.Equal(["b:read"], schemeB.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + } + + [Fact] + public void Projection_is_idempotent() + { + var principal = Principal($$"""{ "{{Audience}}": { "roles": ["Editor"] } }"""); + + ModgudClaimsProjector.Project(principal, Audience); + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Single(principal.FindAll(ClaimTypes.Role)); + } + + [Fact] + public void Groups_are_never_projected() + { + var principal = Principal($$"""{ "{{Audience}}": { "groups": ["Internal"] } }"""); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll("group")); + } + + [Theory] + [InlineData("not-json")] + [InlineData("{}")] + [InlineData("""{"another-api":{"permissions":["x:y"]}}""")] + public void Missing_or_malformed_audience_data_is_a_no_op(string resourceAccess) + { + var principal = Principal(resourceAccess); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll(ModgudClaimTypes.Permission)); + } + + [Fact] + public void Anonymous_principals_are_not_projected() + { + var principal = Principal( + $$"""{ "{{Audience}}": { "permissions": ["policy:write"] } }""", + authenticated: false); + + ModgudClaimsProjector.Project(principal, Audience); + + Assert.Empty(principal.FindAll(ModgudClaimTypes.Permission)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs new file mode 100644 index 00000000..be5603cf --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ModgudPermissionExtensionsTests.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ModgudPermissionExtensionsTests +{ + [Fact] + public void Policy_requires_authentication_and_the_exact_permission() + { + var policy = ModgudPermissionExtensions.BuildPolicy("policy:write"); + + Assert.Contains(policy.Requirements, x => x is DenyAnonymousAuthorizationRequirement); + var claim = Assert.Single(policy.Requirements.OfType()); + Assert.Equal(ModgudClaimTypes.Permission, claim.ClaimType); + Assert.Equal(["policy:write"], claim.AllowedValues); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Empty_permission_is_rejected(string permission) + { + Assert.Throws(() => ModgudPermissionExtensions.BuildPolicy(permission)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs new file mode 100644 index 00000000..5d468870 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/ResourceServer/ResourceServerRegistrationTests.cs @@ -0,0 +1,232 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Modgud.AspNetCore.ResourceServer; + +namespace Modgud.Tests.Unit.ResourceServer; + +public class ResourceServerRegistrationTests +{ + [Fact] + public async Task Default_mode_registers_one_public_jwt_scheme() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + var publicScheme = await schemes.GetSchemeAsync( + ModgudResourceServerDefaults.AuthenticationScheme); + + Assert.Equal(typeof(JwtBearerHandler), publicScheme?.HandlerType); + Assert.Null(await schemes.GetSchemeAsync(ModgudSchemeNames.Introspection)); + Assert.Null(provider.GetService()); + } + + [Fact] + public async Task Reference_only_mode_registers_one_public_introspection_scheme() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = ModgudTokenMode.OnlyReferenceToken; + options.IntrospectionClientSecret = "secret"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + var publicScheme = await schemes.GetSchemeAsync( + ModgudResourceServerDefaults.AuthenticationScheme); + + Assert.Equal(typeof(ModgudIntrospectionHandler), publicScheme?.HandlerType); + Assert.Null(await schemes.GetSchemeAsync(ModgudSchemeNames.Jwt)); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public async Task Both_mode_is_one_public_policy_scheme_with_two_internal_validators() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = "secret"; + }); + using var provider = services.BuildServiceProvider(); + + var schemes = provider.GetRequiredService(); + Assert.Equal( + typeof(PolicySchemeHandler), + (await schemes.GetSchemeAsync(ModgudResourceServerDefaults.AuthenticationScheme)) + ?.HandlerType); + Assert.Equal( + typeof(JwtBearerHandler), + (await schemes.GetSchemeAsync(ModgudSchemeNames.Jwt))?.HandlerType); + Assert.Equal( + typeof(ModgudIntrospectionHandler), + (await schemes.GetSchemeAsync(ModgudSchemeNames.Introspection))?.HandlerType); + + var transformations = services + .Where(x => x.ServiceType == typeof(IClaimsTransformation)) + .ToArray(); + Assert.All( + transformations, + registration => Assert.Equal( + "NoopClaimsTransformation", + registration.ImplementationType?.Name)); + } + + [Theory] + [InlineData("Bearer aaa.bbb.ccc", ModgudSchemeNames.Jwt)] + [InlineData("DPoP aaa.bbb.ccc", ModgudSchemeNames.Jwt)] + [InlineData("Bearer opaque_reference_token", ModgudSchemeNames.Introspection)] + [InlineData("DPoP opaque_reference_token", ModgudSchemeNames.Introspection)] + [InlineData("Bearer one.dot", ModgudSchemeNames.Introspection)] + [InlineData("Bearer one.two.three.four", ModgudSchemeNames.Introspection)] + [InlineData(null, ModgudSchemeNames.Jwt)] + [InlineData("Basic abc", ModgudSchemeNames.Jwt)] + public void Both_mode_routes_by_modgud_token_shape(string? header, string expectedScheme) + { + Assert.Equal(expectedScheme, ServiceCollectionExtensions.SelectTokenScheme(header)); + } + + [Fact] + public async Task Jwt_projection_uses_the_resource_servers_single_audience() + { + var services = Services(); + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api-a"; + options.TokenMode = ModgudTokenMode.Both; + options.IntrospectionClientSecret = "secret"; + }); + await using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + const string resourceAccess = """ + { + "api-a": { "permissions": ["a:read"] }, + "api-b": { "permissions": ["b:read"] } + } + """; + var principal = Principal(resourceAccess); + + await monitor.Get(ModgudSchemeNames.Jwt).Events.OnTokenValidated( + Context(provider, principal, ModgudSchemeNames.Jwt)); + + Assert.Equal( + ["a:read"], + principal.FindAll(ModgudClaimTypes.Permission).Select(x => x.Value)); + } + + [Fact] + public void A_second_modgud_registration_is_rejected() + { + var services = Services(); + services.AddModgudResourceServer(ValidJwt); + + var error = Assert.Throws(() => + services.AddModgudResourceServer(ValidJwt)); + + Assert.Contains("only be called once", error.Message); + } + + [Theory] + [InlineData(ModgudTokenMode.OnlyReferenceToken)] + [InlineData(ModgudTokenMode.Both)] + public void Reference_accepting_modes_require_a_secret(ModgudTokenMode mode) + { + var services = Services(); + + Assert.Throws(() => + services.AddModgudResourceServer(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.TokenMode = mode; + })); + } + + [Fact] + public void Missing_audience_realm_path_and_insecure_authority_are_rejected() + { + AssertInvalid(options => + { + options.Authority = "https://id.example.com"; + options.Audience = ""; + }); + AssertInvalid(options => + { + options.Authority = "https://id.example.com/system"; + options.Audience = "api"; + }); + AssertInvalid(options => + { + options.Authority = "http://id.example.com"; + options.Audience = "api"; + }); + } + + [Fact] + public void Only_jwt_rejects_irrelevant_introspection_credentials() + { + AssertInvalid(options => + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + options.IntrospectionClientSecret = "unused"; + }); + } + + private static ServiceCollection Services() + { + var services = new ServiceCollection(); + services.AddLogging(); + return services; + } + + private static void ValidJwt(ModgudResourceServerOptions options) + { + options.Authority = "https://id.example.com"; + options.Audience = "api"; + } + + private static void AssertInvalid(Action configure) + { + var services = Services(); + Assert.Throws(() => + services.AddModgudResourceServer(configure)); + } + + private static ClaimsPrincipal Principal(string resourceAccess) + { + var identity = new ClaimsIdentity( + [new Claim(ModgudClaimTypes.ResourceAccess, resourceAccess)], + authenticationType: "test"); + return new ClaimsPrincipal(identity); + } + + private static TokenValidatedContext Context( + IServiceProvider services, + ClaimsPrincipal principal, + string schemeName) + { + var http = new DefaultHttpContext { RequestServices = services }; + var scheme = new AuthenticationScheme(schemeName, null, typeof(JwtBearerHandler)); + return new TokenValidatedContext(http, scheme, new JwtBearerOptions()) + { + Principal = principal, + }; + } +} diff --git a/src/dotnet/Modgud.slnx b/src/dotnet/Modgud.slnx index e976c2d4..01a13e6b 100644 --- a/src/dotnet/Modgud.slnx +++ b/src/dotnet/Modgud.slnx @@ -13,7 +13,7 @@ - + diff --git a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj index b199b5b5..e3a6c3c3 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj +++ b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Modgud.TestApps.ResourceApi.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs index 044458e8..f81b9fa1 100644 --- a/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs +++ b/src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs @@ -1,7 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Modgud.Client.AspNetCore; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Modgud.AspNetCore.ResourceServer; using Microsoft.AspNetCore.Authorization; // Disable the default JWT short→long claim translation so we see "sub", "name", @@ -20,23 +19,16 @@ // roles/permissions) // GET /scoped — token-scope based gate ("demo.read") // GET /admin — token-scope based gate ("demo.admin") -// GET /policy/read — RequiresModgudPermission("demo:read") +// GET /policy/read — RequireModgudPermission("demo:read") // — exact-match against pre-expanded permissions -// POST /policy/write — RequiresModgudPermission("demo:write") +// POST /policy/write — RequireModgudPermission("demo:write") // // What the path proves: the IdP issues a JWT with aud=, and the -// Modgud client library (Modgud.Client.AspNetCore) makes sure the -// principal ends up with a resource_access claim — preferring the token's -// own embedded claim (federation v1.1 bakes resource_access into every -// access token at issuance) and falling back to fetching -// /connect/userinfo on JwtBearer's OnTokenValidated event only when the -// token carries none. There's no GetClaimsFromUserInfoEndpoint on -// JwtBearerOptions for the fallback path — that property only exists on -// AddOpenIdConnect. Either source emits resource_access[] = +// Modgud.AspNetCore.ResourceServer validates the token and projects its +// embedded resource_access[] = // { permissions, roles } with bypass tiers (realm:admin, :admin) -// already pre-expanded to concrete strings, and the lib's -// claims-transformation flattens that block onto the principal. -// RequiresModgudPermission then does straight membership match — no HTTP, +// already pre-expanded to concrete strings directly onto the identity. +// RequireModgudPermission then does straight membership match — no HTTP, // no cache, no evaluator on the RS side. var builder = WebApplication.CreateBuilder(args); @@ -49,49 +41,49 @@ // TESTAPPS:TOKENMODE selects how this sample validates access tokens: // "jwt" (default) — self-contained JWT validated locally against the -// realm's JWKS (AddJwtBearer + AddModgudClient). +// realm's JWKS. // "reference" — Modgud's DEFAULT opaque token, validated per-request via -// /connect/introspect (AddModgudReferenceTokenClient). The RS +// /connect/introspect. The RS // introspects with a confidential client whose client_id equals // its audience; supply its secret via TESTAPPS:INTROSPECTIONSECRET. -// Everything downstream — the resource_access projection, RequiresModgudPermission, +// "both" — accepts both formats under one public Modgud scheme and +// dispatches by token shape. +// Everything downstream — the resource_access projection, RequireModgudPermission, // role gates — is identical either way; only the registration differs. -var tokenMode = (builder.Configuration["TESTAPPS:TOKENMODE"] ?? "jwt").Trim().ToLowerInvariant(); - -if (tokenMode == "reference") +var configuredTokenMode = + (builder.Configuration["TESTAPPS:TOKENMODE"] ?? "jwt").Trim().ToLowerInvariant(); +var tokenMode = configuredTokenMode switch { - builder.Services - .AddAuthentication(ModgudReferenceTokenDefaults.AuthenticationScheme) - .AddModgudReferenceTokenClient(o => - { - o.Authority = authority; - o.Audience = audience; // == the introspection client_id (an OAuthApi name) - o.IntrospectionClientSecret = builder.Configuration["TESTAPPS:INTROSPECTIONSECRET"]; - }); -} -else + "jwt" => ModgudTokenMode.OnlyJwt, + "reference" => ModgudTokenMode.OnlyReferenceToken, + "both" => ModgudTokenMode.Both, + _ => throw new InvalidOperationException( + "TESTAPPS:TOKENMODE must be 'jwt', 'reference', or 'both'."), +}; + +builder.Services.AddModgudResourceServer(options => { - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = authority; - options.Audience = audience; - options.RequireHttpsMetadata = false; // dev only - options.MapInboundClaims = false; - options.TokenValidationParameters.NameClaimType = "name"; - options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; - }); - - // Lib hooks JwtBearer.OnTokenValidated to fetch /connect/userinfo and - // merge resource_access onto the principal, then a ClaimsTransformation - // flattens the matching audience block to ClaimTypes.Role / "permission" - // / "group" claims. Plus the RequiresModgudPermission endpoint filter. - builder.Services.AddModgudClient(o => + options.Authority = authority; + options.Audience = audience; + options.TokenMode = tokenMode; + options.RequireHttpsMetadata = false; // dev only + + if (tokenMode is ModgudTokenMode.OnlyReferenceToken or ModgudTokenMode.Both) { - o.Authority = authority; - o.Audience = audience; // must match JwtBearerOptions.Audience above - }); -} + options.IntrospectionClientSecret = + builder.Configuration["TESTAPPS:INTROSPECTIONSECRET"]; + } + + if (tokenMode is ModgudTokenMode.OnlyJwt or ModgudTokenMode.Both) + { + options.ConfigureJwtBearer = jwt => + { + jwt.MapInboundClaims = false; + jwt.TokenValidationParameters.NameClaimType = "name"; + jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + }; + } +}); builder.Services.AddAuthorization(options => { @@ -123,13 +115,13 @@ scopes = user.FindAll("scope").Select(c => c.Value) .Concat(user.FindAll("scp").Select(c => c.Value)) .ToArray(), - // Roles + permissions come from the lib's claims-transformation, which - // reads resource_access[] off the principal. They will be empty + // Roles + permissions come from the authentication scheme's audience-local + // projection of resource_access[]. They will be empty // if the IdP hasn't emitted a block for this audience (e.g. because the // user has no grants in the linked App). Groups are never emitted by the // IdP (hub boundary, federation v1) — there is no "groups" key to read. roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(), - permissions = user.FindAll(ModgudClaimsTransformation.PermissionClaimType) + permissions = user.FindAll(ModgudClaimTypes.Permission) .Select(c => c.Value).ToArray(), claims = user.Claims.Select(c => new { c.Type, c.Value }).ToArray() })).RequireAuthorization(); @@ -145,15 +137,13 @@ .RequireAuthorization("demo.admin"); // Permission-gated endpoints — the post-Step-7-fix path. These exercise: -// incoming bearer → JwtBearer fetches UserInfo → resource_access block -// projected onto principal → filter does exact-match. +// incoming token → scheme-local resource_access projection → ASP.NET +// authorization policy does exact-match. app.MapGet("/policy/read", () => Results.Ok(new { message = "You called demo:read." })) - .RequireAuthorization() - .RequiresModgudPermission("demo:read"); + .RequireModgudPermission("demo:read"); app.MapPost("/policy/write", () => Results.Ok(new { message = "You called demo:write." })) - .RequireAuthorization() - .RequiresModgudPermission("demo:write"); + .RequireModgudPermission("demo:write"); app.Run(); diff --git a/src/landing-page/src/pages/index.astro b/src/landing-page/src/pages/index.astro index 0433e748..dbd7bc9a 100644 --- a/src/landing-page/src/pages/index.astro +++ b/src/landing-page/src/pages/index.astro @@ -404,15 +404,15 @@ const operations = [ Program.cs ASP.NET Core
-
// One audience. One projection.
-builder.Services.AddModgudClient(options =>
+          
// One method: OnlyJwt (default), OnlyReferenceToken, or Both.
+builder.Services.AddModgudResourceServer(options =>
 {
     options.Authority = "https://id.acme.example";
     options.Audience = "orders-api";
 });
 
 app.MapPost("/orders", CreateOrder)
-   .RequiresModgudPermission(
+   .RequireModgudPermission(
        "orders:write");
JWT or reference token From ae63db0c6925d234fa906e12d50b2eac0634695e Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 17:54:54 +0200 Subject: [PATCH 05/34] feat: isolate realm and platform audit data --- docs/admin/auth-log.md | 108 +++--- docs/admin/index.md | 2 +- docs/admin/scheduled-jobs.md | 23 +- docs/concepts/security-model.md | 2 +- docs/contribute/audit-storage-decision.md | 25 ++ docs/operate/database.md | 17 +- docs/operate/observability.md | 5 +- docs/platform/settings.md | 9 +- docs/reference/admin-api.md | 11 +- .../Audit/SecurityAuditStoreTests.cs | 164 ++++---- .../AuthLogTenantVisibilityTests.cs | 170 +++++++-- ...almSettingsTokenLifetimeValidationTests.cs | 31 ++ .../Authorization/UserInfoPerAudienceTests.cs | 18 +- .../ColdStart/ScheduledJobsTenancyTests.cs | 61 ++- .../Admin/Jobs/AccountLifecycleSweepJob.cs | 13 +- .../Features/Admin/Jobs/DcrGcJob.cs | 11 +- .../Admin/Jobs/PlatformAuditPruneJob.cs | 79 ++++ .../Admin/Jobs/SecurityAuditPruneJob.cs | 59 +-- .../Admin/Jobs/SigningKeyJanitorJob.cs | 10 +- .../Provisioning/RealmManifestExporter.cs | 1 + .../Features/Admin/RealmsEndpoints.cs | 128 ++++++- .../Auth/OAuth/DcrRegistrationEndpoints.cs | 32 +- src/dotnet/Modgud.Api/Program.cs | 27 +- .../DTOs/RealmSettings/AuditSettingsDtos.cs | 2 + .../Api/Account/AccountEndpoints.cs | 26 +- .../Api/Account/BootstrapEndpoints.cs | 10 +- .../Api/Account/MagicLinkEndpoints.cs | 24 +- .../Api/Admin/AuditEndpoints.cs | 8 +- .../Api/Admin/AuthLogEndpoints.cs | 353 +++++++++++++----- .../Api/Admin/RealmSettingsEndpoints.cs | 26 +- .../Api/Admin/RecoveryCommands.cs | 180 ++++----- .../ExternalAuth/ExternalLoginProcessor.cs | 118 +++--- .../Saml/DynamicSamlSchemeManager.cs | 12 +- .../Api/ExternalAuth/Saml/SamlLoginFlow.cs | 73 ++-- .../Saml/SamlMetadataRefreshService.cs | 12 +- .../Saml/SamlSpCertificateService.cs | 20 +- .../RealmSettings/RealmSettingsService.cs | 15 +- .../Setup/MartenStoreOptionsExtensions.cs | 11 +- .../Setup/PendingAdminInviteService.cs | 10 +- src/dotnet/Modgud.Authorization/README.md | 4 +- .../Modgud.Domain/Realms/AuditSettings.cs | 10 +- .../Audit/AuditCategories.cs | 6 +- .../Audit/AuditEvents.cs | 44 +-- .../Audit/ISecurityAuditLog.cs | 114 +++--- .../Audit/SecurityAuditEntry.cs | 66 ---- .../Audit/SecurityAuditEvents.cs | 115 ++++++ .../Audit/SecurityAuditLog.cs | 324 ++++++++++++---- .../Authorization/AppRealmSeeder.cs | 3 +- .../DependencyInjection.cs | 16 +- .../OpenIddict/DcrLastUsedTrackerHandler.cs | 12 +- .../RefreshTokenReuseAuditHandler.cs | 13 +- .../Realms/RealmProvisioningService.cs | 56 ++- .../AuthLog/AuthLogAttributionTests.cs | 94 ++--- .../DynamicSamlSchemeManagerTests.cs | 1 + src/frontend-vue/src/layouts/MainLayout.vue | 1 + src/frontend-vue/src/models/realmSettings.ts | 12 + src/frontend-vue/src/router/index.ts | 2 +- .../src/views/admin/AdminLogsView.vue | 15 +- .../src/views/admin/AdminView.vue | 2 +- .../src/views/admin/AuthLogView.vue | 53 ++- .../src/views/admin/PlatformAuditLogView.vue | 102 +++++ .../src/views/admin/RealmSettingsView.vue | 57 ++- 62 files changed, 2023 insertions(+), 1005 deletions(-) create mode 100644 docs/contribute/audit-storage-decision.md create mode 100644 src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs delete mode 100644 src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs create mode 100644 src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs create mode 100644 src/frontend-vue/src/views/admin/PlatformAuditLogView.vue diff --git a/docs/admin/auth-log.md b/docs/admin/auth-log.md index 7937c609..433d06bd 100644 --- a/docs/admin/auth-log.md +++ b/docs/admin/auth-log.md @@ -1,70 +1,76 @@ -# Auth Log +# Security and platform logs -The **Security log** is the audit trail of security-relevant events in this realm that don't belong to a specific user record: failed and rejected logins, lockouts, rate limits, and a handful of operational actions. It's one of two tabs on the admin **Logs** page — the other, **Audit**, is the GDPR-relevant history of changes to users and realm configuration. Each tab is gated by its own permission (`auth-log:read` for Security, `audit-log:read` for Audit), so an admin holding only one of the two only sees that tab. This page describes the **Security** tab. +Administration → **Logs** has two realm-owned tabs and, in the +Control-Plane realm, one additional deployment-wide tab: -Administration → **Logs** → **Security** tab. +- **Audit** is the event-sourced history of user and configuration changes. +- **Security** contains structured threat and operations events owned by the + current realm. +- **Platform** exists only in the Control Plane and contains PII-free, + deployment-wide operations. -![Auth log list](/screenshots/admin-auth-log.png) +`auth-log:read` gates Security, `audit-log:read` gates Audit, and +`control-plane:platform-audit:read` gates Platform. -## What gets logged +## Security events are realm-owned -Each row in the grid surfaces: +A Security event is stored in the physical database of the realm where it +occurred. There is no `Realm` column and no central cross-realm table. The +Control Plane is a normal realm for this purpose: its Security tab shows only +Control-Plane-realm events. -| Column | What | -| --- | --- | -| **Time** | UTC instant the event was logged | -| **Category** | The event's broad bucket (e.g. security, operations) — also offered as filter chips above the grid | -| **Event** | The stable event code, e.g. `security.login_failed_unknown_user`, `security.rate_limit_triggered` | -| **Detail** | Human-readable message for the row | -| **Actor** | The acting principal's username, or the attempted identifier for an unknown-user attempt | -| **IP** | Client IP, taken from `X-Forwarded-For` if a known proxy chain is configured, else the direct `RemoteIpAddress` | -| **Level** | `Info` for ordinary events, `Warning` for failed-login bursts and lockouts, `Error` for unhandled exceptions on the auth path | -| **Realm** | The realm the event was emitted in. Constant (your own realm) for a tenant admin; varies for the control-plane admin who sees the full cross-realm log — see [Per-realm scoping](#per-realm-scoping) | +The structured record can retain forensic context during its short retention: -## Filters +- actor and target subject IDs (separate fields); +- source IP and User-Agent/device context; +- OAuth client, application, session and login-provider IDs; +- authentication method, outcome/reason codes and correlation ID. -The list view supports: +Display text is rendered from the stable event code and structured fields at +read time. Free-form `Actor`, `Reason` and persisted `Message` fields do not +exist. -- **Category chips** — narrow to one bucket at a time (only categories present in the current rows are offered) -- **Free-text search** across user, message -- **Refresh** the grid manually (it also refreshes itself periodically) -- **Clear** the entire log (realm-admin only — destructive) +For a known account, only its subject ID is stored and resolved for display. +After account erasure the row remains useful and displays **Deleted user**. +For an unknown login/reset identifier, Modgud stores only a realm-specific +HMAC fingerprint. The raw or merely masked identifier is never persisted, and +fingerprints cannot be correlated across realms. -## Retention +For a Control-Plane operation against another realm, the acting subject, IP and +User-Agent remain in the Control-Plane realm. The target realm receives only a +non-identifying `ControlPlane` counterpart with the same correlation ID. -Security log entries are kept for **7 days**, then hard-deleted by the `security-audit-prune` [scheduled job](./scheduled-jobs). The window isn't currently realm-configurable — it's the same across every realm in a deployment. +## Retention and deletion -Reads (`GET /api/admin/auth-log`) require `auth-log:read`; clearing -the entire log (`DELETE /api/admin/auth-log`) requires `realm:admin`. +Realm admins configure Security retention under **Realm settings → Logs**. +The default is **7 days** and the allowed range is **1–365 days**. +`security-audit-prune` is a realm job: its configuration and run history live +in that realm DB and it deletes only expired events from that realm DB. -## Per-realm scoping +There is no “Clear log” action or `DELETE /api/admin/auth-log` endpoint. +Manually triggering the prune job still respects the configured cutoff; fresh +events cannot be arbitrarily deleted. Hard-deleting a realm removes its whole +database and therefore all of its Security events immediately. -All auth-log entries are persisted to a single cross-realm store (the -system database) but tagged with the realm they were emitted in. The -read and clear are scoped by the **caller's** realm: +## Platform log -- A **tenant realm-admin** sees — and can clear — only their own - realm's entries. -- The **control-plane realm** (the cross-realm operator, by - `Realm.IsControlPlane`) sees the full cross-realm log and can clear - everything; this is the deployment-wide audit view. This follows the - control-plane **role**, not a fixed slug — if the role is transferred - to another realm, the global view moves with it. +True deployment events—realm provisioning/adoption, Control-Plane transfer +and deployment-wide maintenance—go to a separate `PlatformAuditEvent` type in +the non-tenanted Global Store. That type has no subject, identifier, IP, +User-Agent, OAuth client, application or session fields. -Background / no-tenant work (scheduled jobs, bootstrap) is attributed to -the `system` realm, so those operational events show up in the system -realm's view and in the control-plane view. +The Platform log is read at `GET /api/admin/platform-audit` and never mixes +realm Security events through a hidden cross-database union. Its single +`platform-audit-prune` system job defaults to **365 days** and is configurable +deployment-wide from the Control Plane. It has no clear action. -## GDPR +## API -Security log rows aren't tied to a user record the way the Audit tab's rows are, so there's no per-user erasure step here — the short, fixed 7-day retention is itself the safeguard for the personal data (attempted usernames, IPs) these rows can carry. The **Audit** tab works differently: it's projected from the user event stream, so a GDPR-erased user's audit rows are de-identified rather than deleted, keeping the change history traceable without the personal data. +| Method | Path | Permission | +|---|---|---| +| `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | +| `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` + Control-Plane realm | -## Tips - -::: tip Watch for failed-login clusters -A burst of `security.login_failed_unknown_user` or `security.rate_limit_triggered` rows for the same IP in a short window points at credential-stuffing or account enumeration. Modgud's account lockout (5 attempts → 1 minute lock) already mitigates brute force against a known account, but the pattern is worth a periodic eyeball. -::: - -::: tip Reviewing admin/config changes -For realm settings, OAuth client edits, and other admin actions, check the **Audit** tab instead — this Security tab focuses on threat signals (unknown-actor attempts, rejected external logins, rate limits) plus a handful of operational events. -::: +Both streamless feeds are currently best-effort. The event-sourced Audit tab +has different durability semantics. Do not describe either streamless feed as +a cryptographic or tamper-proof audit chain. diff --git a/docs/admin/index.md b/docs/admin/index.md index 9f202da0..8889dafb 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -48,7 +48,7 @@ Per-realm look and feel. SPA-shell branding plus a beta page-builder editor. ### Operations - [Observability](../operate/observability) — OpenTelemetry metrics + tracing + in-app live activity feed -- [Logs](./auth-log) — a combined **Audit** tab (GDPR audit trail of user/config changes) and **Security** tab (login events, lockouts, rejected logins), gated separately +- [Logs](./auth-log) — realm-owned **Audit** and **Security** tabs; the Control Plane additionally gets a separate PII-free **Platform** tab - [Change Requests](./change-requests) — approve profile changes (when the approval flow is enabled) - [Settings](../platform/settings) — 2FA enforcement, grace period, SMTP, … - [Feature Flags](../operate/feature-flags) — operator-level toggles for beta / WIP surfaces diff --git a/docs/admin/scheduled-jobs.md b/docs/admin/scheduled-jobs.md index a6dfa2cb..9b9b8461 100644 --- a/docs/admin/scheduled-jobs.md +++ b/docs/admin/scheduled-jobs.md @@ -102,14 +102,27 @@ this realm. ### `security-audit-prune` — Security Audit Prune -Hard-deletes security/ops audit entries older than a fixed 7-day retention window. +Hard-deletes this realm's structured Security events after its configured +retention window. -This is a deployment-wide **system job**: it appears only in the current Control-Plane realm and has only one Quartz trigger. +This is a **realm job**: every realm has its own trigger, configuration and run +history. - **Default cron:** `0 0 2 * * ?` (02:00 UTC daily) -- **Parameters:** none — the 7-day retention is fixed and not configurable per realm. -- **What it does:** deletes entries older than the retention window from the single cross-realm audit store in one indexed delete — there's no per-realm iteration for this job. -- **On failure:** logged + inbox-notified. +- **Parameters:** none on the job. Retention is configured under **Realm + settings → Logs** (default 7 days, range 1–365). +- **What it does:** deletes only expired `RealmSecurityAuditEvent` documents + from the owning physical realm DB. +- **On failure:** only that realm's run fails. + +### `platform-audit-prune` — Platform Audit Prune + +Hard-deletes PII-free deployment events from the Global Store. This is a +deployment-wide **system job**, visible only in the Control Plane. + +- **Default cron:** `0 15 2 * * ?` (02:15 UTC daily) +- **Parameter:** `retentionDays` (default 365, range 1–3650) +- **What it does:** deletes expired `PlatformAuditEvent` documents only. ### `system-job-run-history-retention` — System Job-Run-History Retention diff --git a/docs/concepts/security-model.md b/docs/concepts/security-model.md index 925c343a..09b0df04 100644 --- a/docs/concepts/security-model.md +++ b/docs/concepts/security-model.md @@ -41,7 +41,7 @@ This page is one aggregated, honest view of Modgud's OAuth 2.0 / OpenID Connect | DCR / CIMD abuse | Both off by default, gated per realm + API + scope; CIMD's outbound fetch is SSRF-hardened; accepted residual risks (brand impersonation, targeted phishing via redirect URI) are documented rather than hidden. | [Dynamic Client Registration](/admin/dynamic-client-registration), [Client ID Metadata Documents](/admin/client-id-metadata-documents) | | Membership scripts | Auto-membership predicates run through a sandboxed TypeScript-to-LINQ translator, tested against an adversarial suite covering resource exhaustion, native-host escape, type confusion, cross-tenant probing, injection, and information disclosure. | [Automated tests](/contribute/testing/automated-tests) | | Tenant isolation | Realm boundaries are physical database separation, not a query filter. | [Realms](./realms) | -| Operational security | Per-realm rate-limit ceilings on auth endpoints, a 7-day security log for threat signals, and a separate GDPR-aware audit trail for admin/config changes. | [Auth Log](/admin/auth-log) | +| Operational security | Per-realm rate-limit ceilings, realm-owned structured security events with configurable 1–365 day retention (7-day default), and a separate event-sourced audit history. | [Auth Log](/admin/auth-log) | ## Verification diff --git a/docs/contribute/audit-storage-decision.md b/docs/contribute/audit-storage-decision.md new file mode 100644 index 00000000..7ddf75ea --- /dev/null +++ b/docs/contribute/audit-storage-decision.md @@ -0,0 +1,25 @@ +# Audit storage ownership decision + +Status: accepted for pre-1.0 + +F2 (tenant isolation) and F4 (erasure) are resolved as one storage decision: + +1. The Control Plane is a normal realm. Its realm DB contains only its data. +2. Tenant-visible security events live in the owning realm DB; there is no + central realm-attributed table or hidden cross-DB union. +3. True deployment events live in the Global Store using a separate, + compile-time PII-free `PlatformAuditEvent` type. +4. Realm events use explicit actor/target/forensic fields. Free-form Actor, + Reason, Message and generic property bags are not persisted. + Cross-realm Control-Plane writes create correlated events: the actor and + request metadata stay in the Control-Plane realm, while the target realm + receives only an `ActorKind=ControlPlane` counterpart. +5. Known users are referenced by subject ID. Unknown identifiers become + realm-specific HMAC fingerprints before persistence. +6. Account erasure removes identity profile data; short-retention forensic + records keep pseudonymous IDs and technical context until their realm + retention expires. A realm hard-delete removes them with the database. +7. Realm Security retention defaults to 7 days (1–365); Platform retention + defaults to 365 days. Arbitrary clear/delete endpoints do not exist. +8. Delivery durability remains a separate F7 decision; these streamless feeds + are still best-effort. diff --git a/docs/operate/database.md b/docs/operate/database.md index c310b6b3..2c6bfbe7 100644 --- a/docs/operate/database.md +++ b/docs/operate/database.md @@ -44,7 +44,8 @@ data — no event sourcing. | `PasskeyCeremony`, `PasskeyEnrollCeremony` | Single-use passkey login/enrollment ceremony state (native/bearer flow only — the cookie-based web flow keeps its ceremony state in ASP.NET Core session) | TTL ~5 min | | `OpenIddictAuthorizationDocument` | OAuth consent records | `ApplicationId`, `Subject` | | `OpenIddictTokenDocument` | Reference tokens, refresh tokens | `ApplicationId`, `Subject`, `ReferenceId` | -| `SecurityAuditEntry` | Streamless security / ops events (unknown-actor logins, probes, rate-limit hits, recovery-CLI actions). Lives in the **system DB**, attributed to a realm via `Realm`; short hard-retention prune (no per-subject erase) | `Realm`, `Timestamp` | +| `RealmSecurityAuditEvent` | Structured security/ops events owned by this realm. Explicit forensic fields; unknown identifiers are realm-HMACed; configurable 1–365 day hard retention | `Timestamp`, `EventType` | +| `RealmAuditFingerprintKey` | Per-realm random HMAC key for unresolved identifiers | Singleton | | `UserDeletionState` | GDPR delete workflow state | `UserId` | | `UserChangeRequest` | Profile self-service pending changes | Per `(UserId, Type)` | | `Principal` (polymorphic) | Person + Group + ServiceAccount | `mt_doc_type` discriminator | @@ -229,12 +230,13 @@ In the master DB additionally: |---|---| | `realms.mt_tenant_databases` | Marten tenant registry | | `global.mt_doc_realm` | Realm documents | +| `global.mt_doc_platform_audit_event` | PII-free deployment-wide operations | +| `global.mt_doc_job_config` | Deployment-wide job configuration | +| `global.mt_doc_job_run_history_entry` | Deployment-wide job history | -In the system DB (`_system`) additionally: - -| Table | Contents | -|---|---| -| `mt_doc_security_audit_entry` | Cross-realm streamless security / ops audit (`SecurityAuditEntry` — short hard-retention prune) | +Every realm DB, including `_system`, contains its own +`mt_doc_realm_security_audit_event`. No realm DB contains another realm's +security events. ## Backing up realms @@ -247,8 +249,7 @@ team already runs). What's specific to Modgud is *what* to back up: tenant registry (`realms.mt_tenant_databases`) and the global Realm store (`global.mt_doc_realm`); - **`_system`** — the bootstrap system realm, including its - users and its share of the cross-realm security audit - (`mt_doc_security_audit_entry`); + users and its own security events; - **every `_` DB** — one per realm. Because each realm is a physically separate database, backup and diff --git a/docs/operate/observability.md b/docs/operate/observability.md index b54624a7..7366b6df 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -149,7 +149,10 @@ Two limits worth knowing, both because the targeted values have no machine-recog ### Failure modes -The export is **best-effort and lossy by design**. It must never be load-bearing — the tenant audit (`/admin/audit`, `/admin/auth-log`) is a separate, durable pipeline and is unaffected whether export is on or off. +The export is **best-effort and lossy by design**. It must never be load-bearing. +The event-sourced tenant audit (`/admin/audit`) is a separate pipeline. The +structured Security and Platform feeds are also currently best-effort, but are +stored independently of observability export. | Situation | What happens | What to do | | --- | --- | --- | diff --git a/docs/platform/settings.md b/docs/platform/settings.md index 817a1f40..3073d843 100644 --- a/docs/platform/settings.md +++ b/docs/platform/settings.md @@ -100,11 +100,10 @@ email-verification steps work. ## Auth-log retention -The auth log is hard-pruned to a fixed **7-day** window by a daily -scheduled job (visible and manually triggerable from -[Scheduled jobs](../admin/scheduled-jobs)). This window is not -currently runtime-configurable; it applies across every realm. See -[Auth Log](../admin/auth-log). +Each realm configures its own Security-log retention under **Realm settings → +Logs**. The default is **7 days**, the allowed range is **1–365 days**, and the +realm-owned `security-audit-prune` job hard-deletes only expired entries in +that realm DB. See [Security and platform logs](../admin/auth-log). ## Tips diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index 6f3b319f..16f230fc 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -236,14 +236,17 @@ app slug (`realm:read|write`), not under `modgud`. See ## Security log -The security/audit log. Reads are filtered by `category`, `eventType`, -and `limit` query parameters. Clearing the log is destructive and gated -behind the `realm:admin` bypass. +The Security log reads only the current realm's physical database. The +Control-Plane-only Platform log reads PII-free deployment events from the +Global Store. Both accept `category`, `eventType`, and `limit`. | Method | Path | Permission | |---|---|---| | `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | -| `DELETE` | `/api/admin/auth-log` | `realm:admin` | +| `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` | + +Neither surface has a clear/delete endpoint. Retention jobs delete only +expired entries. ## App info diff --git a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs index 460ca26e..dda6a908 100644 --- a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs @@ -1,120 +1,142 @@ +using System.Net; using Marten; using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; using Modgud.Authentication.Gdpr; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Realms; namespace Modgud.Api.Tests.Audit; -/// -/// The streamless security/ops store (logging/audit redesign Track A, §A.5): -/// records about UNidentified actors + operational actions, in the system DB under -/// Art. 6(1)(f) legitimate interest. Two load-bearing claims are tested here: -/// (1) these records are NOT in the per-subject GDPR-erase path — they rely on the -/// short retention window, not erasure (Open Decision #4 = time-expiry only); and -/// (2) clearing the log is itself audited (audit-of-the-audit) with the operator's -/// identity. The control-plane test admin sees + clears the full cross-realm log. -/// [Collection(IntegrationTestCollection.Name)] public class SecurityAuditStoreTests : IntegrationTestBase { public SecurityAuditStoreTests(SharedPostgresFixture fixture) : base(fixture) { } [Fact] - public async Task Streamless_record_survives_user_permanent_erase() + public async Task Structured_forensic_record_survives_subject_erasure_until_retention() { var ct = TestContext.Current.CancellationToken; - - // A registered user whose email also appears as the ATTEMPTED actor on a - // pre-registration failed-login row in the streamless store. - const string email = "boundary-victim@acme.com"; - var user = await Factory.CreateTestUserWithIdentityAsync("Boundary", "Victim", "bv", email); - + var user = await Factory.CreateTestUserWithIdentityAsync( + "Boundary", "Victim", "bv", "boundary-victim@acme.com"); var rowId = Guid.NewGuid(); + await using (var write = GetTenantedDocumentSession("system")) { - write.Store(new SecurityAuditEntry + write.Store(new RealmSecurityAuditEvent { Id = rowId, Timestamp = DateTimeOffset.UtcNow, - Level = "Warning", - EventType = AuditEvents.LoginFailedUnknownUser, - Actor = email, - Ip = "203.0.113.50", - Realm = "system", - Message = $"Login failed for {email} — user not found or inactive", + Severity = AuditSeverity.Warning, + EventType = AuditEvents.LoginFailed, + Category = AuditEvents.CategoryOf(AuditEvents.LoginFailed), + ActorKind = AuditActorKind.User, + TargetSubjectId = user.Id, + IpAddress = "203.0.113.50", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-credentials", }); await write.SaveChangesAsync(ct); } - // Permanent-erase the user. The streamless store has no user stream to attach - // to and is deliberately OUTSIDE the per-subject erase path. using (var scope = Factory.Services.CreateScope()) { var gdpr = scope.ServiceProvider.GetRequiredService(); - var r = await gdpr.PermanentlyEraseAsync(user.Id, adminUserId: null, reason: "streamless-boundary-test", ct); - Assert.False(r.IsError, r.IsError ? r.FirstError.Description : null); + var result = await gdpr.PermanentlyEraseAsync( + user.Id, adminUserId: null, reason: "security-retention-test", ct); + Assert.False(result.IsError, result.IsError ? result.FirstError.Description : null); } - // The streamless record SURVIVES the erase (it expires only via retention). - await using (var read = GetTenantedDocumentSession("system")) - { - var survived = await read.LoadAsync(rowId, ct); - Assert.NotNull(survived); - Assert.Equal(email, survived!.Actor); - } + await using var read = GetTenantedDocumentSession("system"); + var survived = await read.LoadAsync(rowId, ct); + Assert.NotNull(survived); + Assert.Equal(user.Id, survived!.TargetSubjectId); + Assert.Equal("203.0.113.50", survived.IpAddress); } [Fact] - public async Task Clear_is_audited_with_the_operator_identity() + public async Task Unknown_identifier_is_persisted_only_as_realm_hmac() { var ct = TestContext.Current.CancellationToken; + const string rawIdentifier = "Unknown.Person@Example.test"; + var marker = $"hmac-test-{Guid.NewGuid():N}"; + var otherRealm = ($"hmac-{Guid.NewGuid():N}")[..13]; + var provisioned = await Factory.Services + .GetRequiredService() + .CreateRealmAsync(new CreateRealmDto + { + Slug = otherRealm, + DisplayName = "HMAC isolation", + Domains = [$"{otherRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{otherRealm}.test", + }, + }, ct); + Assert.False(provisioned.IsError); + var audit = Factory.Services.GetRequiredService(); - // Something to clear. - await using (var write = GetTenantedDocumentSession("system")) + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) { - write.Store(new SecurityAuditEntry + audit.Record(new SecurityAuditRecord { - Id = Guid.NewGuid(), - Timestamp = DateTimeOffset.UtcNow, - Level = "Warning", EventType = AuditEvents.LoginFailedUnknownUser, - Actor = "to-be-cleared", - Realm = "system", - Message = "seed row for clear test", + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = rawIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, + }); + } + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter(otherRealm)) + { + audit.Record(new SecurityAuditRecord + { + EventType = AuditEvents.LoginFailedUnknownUser, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = rawIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, }); - await write.SaveChangesAsync(ct); } - // Control-plane admin clears the full cross-realm log. - var resp = await Client.DeleteAsync("/api/admin/auth-log", ct); - resp.EnsureSuccessStatusCode(); - - // The clear emits a typed audit.log_cleared record AFTER the wipe (the - // forensic trail of who cleared what). It rides the best-effort async writer, - // so poll briefly for it to land. - var cleared = await PollForAsync( - r => r.EventType == AuditEvents.AuditLogCleared, ct); - - Assert.NotNull(cleared); - Assert.Equal("cleared", cleared!.Status); - Assert.False(string.IsNullOrEmpty(cleared.Actor)); - Assert.NotEqual("(unknown)", cleared.Actor); - } - - private async Task PollForAsync( - Func predicate, CancellationToken ct) - { - for (var i = 0; i < 25; i++) + RealmSecurityAuditEvent? systemRecorded = null; + RealmSecurityAuditEvent? acmeRecorded = null; + for (var attempt = 0; attempt < 25 && + (systemRecorded is null || acmeRecorded is null); attempt++) { - await using (var read = GetTenantedDocumentSession("system")) + await using (var system = GetTenantedDocumentSession("system")) { - var hit = (await read.Query().ToListAsync(ct)) - .FirstOrDefault(predicate); - if (hit is not null) return hit; + systemRecorded = await system.Query() + .FirstOrDefaultAsync(x => x.ReasonCode == marker, ct); } - await Task.Delay(200, ct); + await using (var acme = GetTenantedDocumentSession(otherRealm)) + { + acmeRecorded = await acme.Query() + .FirstOrDefaultAsync(x => x.ReasonCode == marker, ct); + } + if (systemRecorded is null || acmeRecorded is null) + await Task.Delay(200, ct); } - return null; + + Assert.NotNull(systemRecorded); + Assert.NotNull(acmeRecorded); + Assert.Matches("^[0-9a-f]{64}$", systemRecorded!.UnknownIdentifierFingerprint); + Assert.DoesNotContain( + rawIdentifier, + systemRecorded.UnknownIdentifierFingerprint!, + StringComparison.OrdinalIgnoreCase); + Assert.NotEqual( + systemRecorded.UnknownIdentifierFingerprint, + acmeRecorded!.UnknownIdentifierFingerprint); + } + + [Fact] + public async Task Security_log_has_no_clear_endpoint() + { + var response = await Client.DeleteAsync( + "/api/admin/auth-log", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs index 8445af48..417f821e 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/AuthLogTenantVisibilityTests.cs @@ -1,57 +1,159 @@ +using System.Net; using System.Net.Http.Json; +using Marten; +using Microsoft.Extensions.DependencyInjection; using Modgud.Api.Tests.Infrastructure; +using Modgud.Application.DTOs.Realms; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; namespace Modgud.Api.Tests.Authorization; -/// -/// Streamless security-store entries live in the system DB and are attributed to a -/// realm. The read endpoint (GET /api/admin/auth-log) reaches that system DB -/// and returns the realm field; the control-plane (system) realm — which the default -/// test admin runs in — sees the full cross-realm log INCLUDING control-plane-only -/// (PlatformOnly) operational rows. The per-realm + tenant-visibility exclusion -/// of the filter itself is unit-tested deterministically in -/// AuthLogAttributionTests (a tenant realm-admin authenticated request needs -/// full multi-realm host routing + a per-tenant login, out of proportion here). -/// [Collection(IntegrationTestCollection.Name)] public class AuthLogTenantVisibilityTests : IntegrationTestBase { public AuthLogTenantVisibilityTests(SharedPostgresFixture fixture) : base(fixture) { } - private sealed record Row(string Message, string? Realm); + private sealed record RealmRow(string ReasonCode); + private sealed record PlatformRow(string? TargetRealmSlug, string? OperationCode); [Fact] - public async Task Read_AsControlPlaneAdmin_ReturnsAllRealms_IncludingPlatformOnly() + public async Task ControlPlane_realm_log_reads_only_its_own_physical_database() { var ct = TestContext.Current.CancellationToken; + var otherRealm = ($"log-{Guid.NewGuid():N}")[..12]; + var provisioned = await Factory.Services + .GetRequiredService() + .CreateRealmAsync(new CreateRealmDto + { + Slug = otherRealm, + DisplayName = "Log isolation", + Domains = [$"{otherRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{otherRealm}.test", + }, + }, ct); + Assert.False(provisioned.IsError); - // Entries live in the system DB regardless of which realm emitted them. - await using (var write = GetTenantedDocumentSession("system")) + await using (var system = GetTenantedDocumentSession("system")) { - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-system", Realm = "system", PlatformOnly = false }); - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-acme", Realm = "acme", PlatformOnly = false }); - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Info", EventType = AuditEvents.LoginFailedUnknownUser, Message = "sk-vis-unattributed", Realm = null, PlatformOnly = false }); - // A control-plane-only operational row — visible to the control-plane reader. - write.Store(new SecurityAuditEntry { Timestamp = DateTimeOffset.UtcNow, Level = "Warning", EventType = AuditEvents.RecoveryCliInvoked, Message = "sk-vis-platform", Realm = "acme", PlatformOnly = true }); - await write.SaveChangesAsync(ct); + system.Store(NewRealmEvent("system-only")); + await system.SaveChangesAsync(ct); + } + await using (var acme = GetTenantedDocumentSession(otherRealm)) + { + acme.Store(NewRealmEvent("acme-only")); + await acme.SaveChangesAsync(ct); } - // The default Client is a realm-admin in the system (control-plane) realm. - var entries = await Client.GetFromJsonAsync>( + var rows = await Client.GetFromJsonAsync>( "/api/admin/auth-log?limit=500", ct); - Assert.NotNull(entries); - var byMessage = entries! - .Where(e => e.Message.StartsWith("sk-vis-")) - .ToDictionary(e => e.Message, e => e.Realm); - - // Control-plane sees its own realm AND other realms AND unattributed events AND - // control-plane-only operational rows. - Assert.Equal("system", byMessage["sk-vis-system"]); - Assert.Equal("acme", byMessage["sk-vis-acme"]); - Assert.True(byMessage.ContainsKey("sk-vis-unattributed")); - Assert.Null(byMessage["sk-vis-unattributed"]); - Assert.True(byMessage.ContainsKey("sk-vis-platform")); // PlatformOnly row visible to control-plane + Assert.NotNull(rows); + Assert.Contains(rows!, x => x.ReasonCode == "system-only"); + Assert.DoesNotContain(rows!, x => x.ReasonCode == "acme-only"); + } + + [Fact] + public async Task Platform_log_is_a_separate_global_store_surface() + { + var ct = TestContext.Current.CancellationToken; + using (var scope = Factory.Services.CreateScope()) + { + var global = scope.ServiceProvider.GetRequiredService(); + await using var session = global.LightweightSession(); + session.Store(new PlatformAuditEvent + { + Timestamp = DateTimeOffset.UtcNow, + EventType = AuditEvents.RealmProvisioned, + Category = AuditEvents.CategoryOf(AuditEvents.RealmProvisioned), + TargetRealmSlug = "acme", + OperationCode = "visibility-test", + OutcomeCode = AuditOutcomes.Succeeded, + }); + await session.SaveChangesAsync(ct); + } + + var rows = await Client.GetFromJsonAsync>( + "/api/admin/platform-audit?limit=500", ct); + + Assert.NotNull(rows); + Assert.Contains(rows!, x => + x.TargetRealmSlug == "acme" && x.OperationCode == "visibility-test"); } + + [Fact] + public async Task ControlPlane_action_keeps_actor_in_actor_realm_and_writes_pii_free_counterpart() + { + var ct = TestContext.Current.CancellationToken; + var targetRealm = ($"cp-audit-{Guid.NewGuid():N}")[..16]; + var response = await Client.PostAsJsonAsync( + "/api/admin/realms", + new CreateRealmDto + { + Slug = targetRealm, + DisplayName = "Cross-realm audit", + Domains = [$"{targetRealm}.test"], + InitialAdmin = new InitialAdminDto + { + UserName = "admin", + Email = $"admin@{targetRealm}.test", + }, + }, + ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + RealmSecurityAuditEvent? actorEvent = null; + RealmSecurityAuditEvent? counterpart = null; + for (var attempt = 0; attempt < 30 && + (actorEvent is null || counterpart is null); attempt++) + { + await using (var actorRealm = GetTenantedDocumentSession("system")) + { + actorEvent = await actorRealm.Query() + .FirstOrDefaultAsync( + x => x.EventType == AuditEvents.ControlPlaneRealmOperation && + x.TargetRealmSlug == targetRealm && + x.OperationCode == "provision-realm", + ct); + } + await using (var target = GetTenantedDocumentSession(targetRealm)) + { + counterpart = await target.Query() + .FirstOrDefaultAsync( + x => x.EventType == AuditEvents.ControlPlaneRealmOperation && + x.OperationCode == "provision-realm", + ct); + } + + if (actorEvent is null || counterpart is null) + await Task.Delay(200, ct); + } + + Assert.NotNull(actorEvent); + Assert.NotNull(counterpart); + Assert.Equal(AuditActorKind.User, actorEvent!.ActorKind); + Assert.NotNull(actorEvent.ActorSubjectId); + Assert.Equal(targetRealm, actorEvent.TargetRealmSlug); + Assert.Equal(actorEvent.CorrelationId, counterpart!.CorrelationId); + Assert.Equal(AuditActorKind.ControlPlane, counterpart.ActorKind); + Assert.Null(counterpart.ActorSubjectId); + Assert.Null(counterpart.TargetSubjectId); + Assert.Null(counterpart.IpAddress); + Assert.Null(counterpart.UserAgent); + Assert.Null(counterpart.TargetRealmSlug); + } + + private static RealmSecurityAuditEvent NewRealmEvent(string reasonCode) => new() + { + Timestamp = DateTimeOffset.UtcNow, + EventType = AuditEvents.LoginFailedUnknownUser, + Category = AuditEvents.CategoryOf(AuditEvents.LoginFailedUnknownUser), + ActorKind = AuditActorKind.AnonymousIdentifier, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = reasonCode, + }; } diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs index e1017d59..6412e07f 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/RealmSettingsTokenLifetimeValidationTests.cs @@ -84,6 +84,37 @@ public async Task Cimd_OutOfBandLifetimes_Rejected() Assert.False(ok.IsError); } + [Fact] + public async Task SecurityAuditRetention_OutsideRealmBounds_IsRejected() + { + using var scope = NewSystemTenantScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + + foreach (var invalidDays in new[] { 0, 366 }) + { + var invalid = await settings.PatchAsync(new UpdateRealmSettingsDto + { + Audit = new UpdateAuditSettingsDto + { + SecurityRetentionDays = invalidDays, + }, + }, ct); + Assert.True(invalid.IsError); + } + + var valid = await settings.PatchAsync(new UpdateRealmSettingsDto + { + Audit = new UpdateAuditSettingsDto + { + SecurityRetentionDays = 30, + }, + }, ct); + + Assert.False(valid.IsError); + Assert.Equal(30, valid.Value.Audit.SecurityRetentionDays); + } + private IServiceScope NewSystemTenantScope() { var scope = Factory.Services.CreateScope(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index 1a98c63c..0a499634 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -280,26 +280,26 @@ await CreateOAuthClientAsync( } // The reuse rejection emits a best-effort security event on the async - // writer — poll briefly for it to land in the system-tenant streamless store. - var recorded = await PollForSecurityAuditEntryAsync( + // writer — poll briefly for it to land in the owning realm database. + var recorded = await PollForRealmSecurityAuditEventAsync( e => e.EventType == AuditEvents.RefreshTokenReuseDetected, TestContext.Current.CancellationToken); Assert.NotNull(recorded); - Assert.Equal("Warning", recorded!.Level); - Assert.Equal("revoked", recorded.Status); - Assert.Contains(clientId, recorded.Reason ?? "", StringComparison.Ordinal); - Assert.Equal(testUser.Id.ToString(), recorded.Actor); + Assert.Equal(AuditSeverity.Warning, recorded!.Severity); + Assert.Equal(AuditOutcomes.Blocked, recorded.OutcomeCode); + Assert.Equal(clientId, recorded.OAuthClientId); + Assert.Equal(testUser.Id, recorded.ActorSubjectId); } - private async Task PollForSecurityAuditEntryAsync( - Func predicate, CancellationToken ct) + private async Task PollForRealmSecurityAuditEventAsync( + Func predicate, CancellationToken ct) { for (var i = 0; i < 25; i++) { await using (var read = GetTenantedDocumentSession("system")) { - var hit = (await read.Query().ToListAsync(ct)) + var hit = (await read.Query().ToListAsync(ct)) .FirstOrDefault(predicate); if (hit is not null) return hit; } diff --git a/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs index 4f45f712..d17104d7 100644 --- a/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs +++ b/src/dotnet/Modgud.Api.Tests/ColdStart/ScheduledJobsTenancyTests.cs @@ -46,16 +46,21 @@ public async Task Realm_schedules_are_independent_and_system_jobs_are_ControlPla var controlPlaneRealmKey = new JobKey(DcrGcJob.Key, "realm:system"); var tenantRealmKey = new JobKey(DcrGcJob.Key, $"realm:{tenantSlug}"); - var singletonSystemKey = new JobKey(SecurityAuditPruneJob.Key, "system"); + var controlPlaneSecurityKey = + new JobKey(SecurityAuditPruneJob.Key, "realm:system"); + var tenantSecurityKey = + new JobKey(SecurityAuditPruneJob.Key, $"realm:{tenantSlug}"); + var singletonPlatformAuditKey = + new JobKey(PlatformAuditPruneJob.Key, "system"); var singletonSystemRetentionKey = new JobKey(SystemJobRunHistoryRetentionJob.Key, "system"); Assert.True(await scheduler.CheckExists(controlPlaneRealmKey, ct)); Assert.True(await scheduler.CheckExists(tenantRealmKey, ct)); - Assert.True(await scheduler.CheckExists(singletonSystemKey, ct)); + Assert.True(await scheduler.CheckExists(controlPlaneSecurityKey, ct)); + Assert.True(await scheduler.CheckExists(tenantSecurityKey, ct)); + Assert.True(await scheduler.CheckExists(singletonPlatformAuditKey, ct)); Assert.True(await scheduler.CheckExists(singletonSystemRetentionKey, ct)); - Assert.False(await scheduler.CheckExists( - new JobKey(SecurityAuditPruneJob.Key, $"realm:{tenantSlug}"), ct)); Assert.False(await scheduler.CheckExists( new JobKey(SystemJobRunHistoryRetentionJob.Key, $"realm:{tenantSlug}"), ct)); var globalStore = factory.Services.GetRequiredService(); @@ -64,7 +69,9 @@ await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => { var visible = await jobs.GetAllAsync(ct); Assert.Contains(visible, - j => j.Key == SecurityAuditPruneJob.Key && j.Scope == nameof(JobScope.System)); + j => j.Key == SecurityAuditPruneJob.Key && j.Scope == nameof(JobScope.Realm)); + Assert.Contains(visible, + j => j.Key == PlatformAuditPruneJob.Key && j.Scope == nameof(JobScope.System)); Assert.Contains(visible, j => j.Key == SystemJobRunHistoryRetentionJob.Key && j.Scope == nameof(JobScope.System)); @@ -80,14 +87,20 @@ await InTenantAsync(factory, TenantConstants.SystemTenantId, async jobs => CronOverride = "0 17 1 * * ?", Enabled = true, }, ct); - await jobs.TriggerNowAsync(SecurityAuditPruneJob.Key, ct: ct); + await jobs.UpdateAsync(PlatformAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 18 1 * * ?", + Enabled = true, + }, ct); + await jobs.TriggerNowAsync(PlatformAuditPruneJob.Key, ct: ct); }); await InTenantAsync(factory, tenantSlug, async jobs => { var visible = await jobs.GetAllAsync(ct); Assert.DoesNotContain(visible, j => j.Scope == nameof(JobScope.System)); - Assert.Null(await jobs.GetAsync(SecurityAuditPruneJob.Key, ct)); + Assert.NotNull(await jobs.GetAsync(SecurityAuditPruneJob.Key, ct)); + Assert.Null(await jobs.GetAsync(PlatformAuditPruneJob.Key, ct)); Assert.Null(await jobs.GetAsync(SystemJobRunHistoryRetentionJob.Key, ct)); await jobs.UpdateAsync(DcrGcJob.Key, new JobUpdateDto @@ -95,6 +108,11 @@ await InTenantAsync(factory, tenantSlug, async jobs => CronOverride = "0 0 18 * * ?", Enabled = true, }, ct); + await jobs.UpdateAsync(SecurityAuditPruneJob.Key, new JobUpdateDto + { + CronOverride = "0 19 1 * * ?", + Enabled = true, + }, ct); }); Assert.Equal( @@ -103,25 +121,32 @@ await InTenantAsync(factory, tenantSlug, async jobs => Assert.Equal( "0 0 18 * * ?", await GetCronAsync(scheduler, tenantRealmKey, ct)); + Assert.Equal( + "0 17 1 * * ?", + await GetCronAsync(scheduler, controlPlaneSecurityKey, ct)); + Assert.Equal( + "0 19 1 * * ?", + await GetCronAsync(scheduler, tenantSecurityKey, ct)); var systemRun = await WaitForGlobalManualRunAsync( - factory, SecurityAuditPruneJob.Key, ct); + factory, PlatformAuditPruneJob.Key, ct); Assert.NotNull(systemRun); await using (var globalSession = globalStore.QuerySession()) { var systemConfig = await globalSession.LoadAsync( - SecurityAuditPruneJob.Key, ct); - Assert.Equal("0 17 1 * * ?", systemConfig?.CronOverride); + PlatformAuditPruneJob.Key, ct); + Assert.Equal("0 18 1 * * ?", systemConfig?.CronOverride); } await using (var tenantMetadataSession = factory.Services .GetRequiredService() .QuerySession(TenantConstants.SystemTenantId)) { - Assert.Null(await tenantMetadataSession.LoadAsync( - SecurityAuditPruneJob.Key, ct)); + var realmConfig = await tenantMetadataSession.LoadAsync( + SecurityAuditPruneJob.Key, ct); + Assert.Equal("0 17 1 * * ?", realmConfig?.CronOverride); Assert.False(await tenantMetadataSession.Query() - .AnyAsync(h => h.JobKey == SecurityAuditPruneJob.Key, ct)); + .AnyAsync(h => h.JobKey == PlatformAuditPruneJob.Key, ct)); } // Disabled means manual-only: the durable realm job remains, but its @@ -171,7 +196,7 @@ await scheduler.GetTriggersOfJob(tenantRealmKey, ct), // while the reserved Quartz identity remains a single instance. var transferred = await realms.TransferControlPlaneAsync(tenantSlug, ct); Assert.False(transferred.IsError); - var systemDetail = await scheduler.GetJobDetail(singletonSystemKey, ct); + var systemDetail = await scheduler.GetJobDetail(singletonPlatformAuditKey, ct); Assert.NotNull(systemDetail); Assert.Equal( tenantSlug, @@ -187,9 +212,13 @@ await InTenantAsync(factory, tenantSlug, async jobs => { var visible = await jobs.GetAllAsync(ct); Assert.Contains(visible, - j => j.Key == SecurityAuditPruneJob.Key + j => j.Key == PlatformAuditPruneJob.Key && j.Scope == nameof(JobScope.System) - && j.EffectiveCron == "0 17 1 * * ?"); + && j.EffectiveCron == "0 18 1 * * ?"); + Assert.Contains(visible, + j => j.Key == SecurityAuditPruneJob.Key + && j.Scope == nameof(JobScope.Realm) + && j.EffectiveCron == "0 19 1 * * ?"); }); } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs index 2b93d03e..88b26c94 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs @@ -54,11 +54,14 @@ public async Task Execute(IJobExecutionContext context) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.AccountLifecycleSwept, - Level = "Info", - Realm = realmSlug, - Status = "swept", - Reason = $"reminded={reminded} selfErased={erased} autoPurged={purged} inviteCodesPruned={inviteCodesPruned}", - Message = $"Account-lifecycle sweep — Realm={realmSlug} Reminded={reminded} SelfErased={erased} AutoPurged={purged} InviteCodesPruned={inviteCodesPruned}", + RealmSlug = realmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "sweep", + RemindedCount = reminded, + SelfErasedCount = erased, + AutoPurgedCount = purged, + InviteCodesPrunedCount = inviteCodesPruned, }); } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs index 36d4a6d4..5b1ac2d3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs @@ -93,11 +93,12 @@ private async Task SweepRealmAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.DcrClientGarbageCollected, - Realm = tenantId, - Level = "Info", - Status = "collected", - Reason = $"clientId {state.ClientId}, ttl {dcr.GcTtlDays}d", - Message = $"DCR client garbage-collected: {state.ClientId}", + RealmSlug = tenantId, + ActorKind = AuditActorKind.System, + OAuthClientId = state.ClientId, + OutcomeCode = AuditOutcomes.Pruned, + OperationCode = "garbage-collect", + RetentionDays = dcr.GcTtlDays, }); } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs new file mode 100644 index 00000000..aa7ae423 --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/PlatformAuditPruneJob.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Marten; +using Modgud.Application.Scheduling; +using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Scheduling; +using Quartz; + +namespace Modgud.Api.Features.Admin.Jobs; + +/// +/// Single deployment-wide hard-prune for PII-free platform events in the +/// non-tenanted Global Store. +/// +[DisallowConcurrentExecution] +public sealed class PlatformAuditPruneJob(IGlobalStore globalStore) : IJob +{ + public const string Key = "platform-audit-prune"; + public const string Name = "Platform Audit Prune"; + public const string Description = + "Hard-deletes PII-free deployment-wide platform events after the configured retention period."; + public const string DefaultCron = "0 15 2 * * ?"; + public const string RetentionDaysKey = "retentionDays"; + public const int DefaultRetentionDays = 365; + + public static IReadOnlyList GetParameterSchema() => + [ + new() + { + Key = RetentionDaysKey, + Label = "Retention in days", + Type = JobParameterType.Number, + Default = DefaultRetentionDays, + Description = "Deployment-wide platform-event retention (1–3650 days).", + }, + ]; + + public async Task Execute(IJobExecutionContext context) + { + var ct = context.CancellationToken; + await using var session = globalStore.LightweightSession(); + var config = await session.LoadAsync(Key, ct); + var retentionDays = ReadInt(config?.Parameters, RetentionDaysKey) ?? DefaultRetentionDays; + if (retentionDays is < 1 or > 3650) + throw new JobExecutionException( + $"Platform audit retention must be between 1 and 3650 days, got {retentionDays}."); + + var cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays); + var doomed = await session.Query() + .CountAsync(x => x.Timestamp < cutoff, ct); + session.DeleteWhere(x => x.Timestamp < cutoff); + await session.SaveChangesAsync(ct); + + context.Result = doomed == 0 + ? "No entries to prune" + : $"Pruned {doomed} platform event(s) older than {retentionDays} day(s)"; + } + + private static int? ReadInt( + IReadOnlyDictionary? values, + string key) + { + if (values is null || !values.TryGetValue(key, out var value) || value is null) + return null; + + return value switch + { + int number => number, + long number => checked((int)number), + double number => checked((int)number), + JsonElement { ValueKind: JsonValueKind.Number } json + when json.TryGetInt32(out var number) => number, + JsonElement { ValueKind: JsonValueKind.String } json + when int.TryParse(json.GetString(), out var number) => number, + string text when int.TryParse(text, out var number) => number, + _ => null, + }; + } +} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs index 76ffa29a..eb81f7b3 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SecurityAuditPruneJob.cs @@ -1,65 +1,42 @@ using Marten; -using Microsoft.Extensions.DependencyInjection; -using Quartz; +using Modgud.Authentication.RealmSettings; using Modgud.Infrastructure.Audit; -using Modgud.Infrastructure.Persistence.Tenancy; +using Quartz; namespace Modgud.Api.Features.Admin.Jobs; /// -/// Daily hard-prune of the streamless security/ops audit store -/// (). Replaces the legacy -/// AuthLogPersistenceService cleanup loop with a Quartz job admins can see, -/// re-cron, and trigger from /admin/jobs. -/// -/// The short, FIXED retention window is the GDPR proportionality control -/// for this store: it holds personal data about unidentified actors (attempted -/// identifiers, IPs under CJEU Breyer) processed under Art. 6(1)(f) legitimate -/// interest, with no per-subject erase path — so a genuine hard delete on a tight -/// window keeps the processing proportionate. Deliberately NOT per-realm configurable -/// (unlike the per-realm GDPR-audit visibility window, which is a view bound, -/// not a deletion). See the maintainers' logging-audit-redesign design note §A.6 -/// + the Legitimate-Interest Assessment. -/// -/// The store is a single cross-realm doc set in the system DB, so this is one -/// indexed delete — no per-realm iteration. +/// Realm-owned hard-prune. Quartz creates one instance per realm; it reads the +/// owning realm's policy and deletes only that physical database's events. /// [DisallowConcurrentExecution] -public class SecurityAuditPruneJob(IServiceScopeFactory scopeFactory) : IJob +public sealed class SecurityAuditPruneJob( + IDocumentSession session, + IRealmSettingsService realmSettings) : IJob { public const string Key = "security-audit-prune"; public const string Name = "Security Audit Prune"; public const string Description = - "Hard-deletes streamless security/ops audit entries older than the fixed " + - "short retention window (7 days). This retention is the GDPR proportionality " + - "control for the legitimate-interest data the store holds; deliberately fixed, " + - "not per-realm configurable."; - - /// Fixed short hard-retention for the legitimate-interest streamless store. - /// (The per-realm GDPR-audit visibility window is a separate, configurable concept.) - public static readonly TimeSpan Retention = TimeSpan.FromDays(7); - - /// 02:00 UTC daily. + "Hard-deletes this realm's structured security events after its configured retention period."; public const string DefaultCron = "0 0 2 * * ?"; public async Task Execute(IJobExecutionContext context) { var ct = context.CancellationToken; - - using var scope = scopeFactory.CreateScope(); - var store = scope.ServiceProvider.GetRequiredService(); - - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - - var cutoff = DateTimeOffset.UtcNow - Retention; - var doomed = await session.Query() + var settings = await realmSettings.LoadAsync(ct); + var retentionDays = settings.Audit?.SecurityRetentionDays ?? 7; + if (retentionDays is < 1 or > 365) + throw new JobExecutionException( + $"SecurityRetentionDays must be between 1 and 365, got {retentionDays}."); + + var cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays); + var doomed = await session.Query() .CountAsync(x => x.Timestamp < cutoff, ct); - - session.DeleteWhere(x => x.Timestamp < cutoff); + session.DeleteWhere(x => x.Timestamp < cutoff); await session.SaveChangesAsync(ct); context.Result = doomed == 0 ? "No entries to prune" - : $"Pruned {doomed} security-audit entr(ies) older than {Retention.TotalDays:0} day(s)"; + : $"Pruned {doomed} realm security event(s) older than {retentionDays} day(s)"; } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs index fbd6ef30..0c4e875d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs @@ -47,11 +47,11 @@ public async Task Execute(IJobExecutionContext context) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SigningKeyPurged, - Realm = realmSlug, - Level = "Info", - Status = "purged", - Reason = $"purged {purged} expired retired key(s)", - Message = $"signing-key janitor purged {purged} expired retired key(s)", + RealmSlug = realmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Pruned, + OperationCode = "purge-expired-retired-keys", + Count = purged, }); } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs index ab73724c..6cccaa7d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -270,6 +270,7 @@ public async Task> ExportRealmAsync(string slug, Cancella Audit = new UpdateAuditSettingsDto { VisibilityWindowDays = s.Audit.VisibilityWindowDays, + SecurityRetentionDays = s.Audit.SecurityRetentionDays, }, }; diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index 856a91d3..9eee6697 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Claims; using ErrorOr; using Modgud.Api.Features.Admin.Provisioning; @@ -9,6 +10,7 @@ using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Services; using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Observability; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; @@ -58,6 +60,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, IRealmProvisioningService svc, IServiceProvider sp, HttpContext http, + ISecurityAuditLog securityAudit, ILoggerFactory loggerFactory, CancellationToken ct) => { @@ -107,6 +110,14 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, realm.Slug); await svc.RollbackProvisionedRealmAsync(realm.Slug, ct); + RecordControlPlaneRealmOperation( + securityAudit, + http, + realm.Slug, + "provision-realm", + AuditOutcomes.Failed, + "bootstrap-invite-failed", + writeTargetRealm: false); return Results.Problem( statusCode: StatusCodes.Status500InternalServerError, @@ -116,6 +127,12 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, + "See the server logs / the realm error feed for the underlying cause."); } + RecordControlPlaneRealmOperation( + securityAudit, + http, + realm.Slug, + "provision-realm"); + return Results.Created( $"{path}/admin/realms/{realm.Slug}", new CreatedRealmDto @@ -144,6 +161,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, IRealmProvisioningService svc, IServiceProvider sp, HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var realm = await svc.GetRealmBySlugAsync(slug, ct); @@ -194,6 +212,12 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, ct); } + RecordControlPlaneRealmOperation( + securityAudit, + http, + slug, + "resend-bootstrap-invite"); + return Results.Ok(new InitialAdminInviteDto { UserName = issued.UserName, @@ -209,9 +233,19 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, string slug, UpdateRealmDto dto, IRealmProvisioningService svc, + HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var result = await svc.UpdateRealmAsync(slug, dto, ct); + if (!result.IsError) + { + RecordControlPlaneRealmOperation( + securityAudit, + http, + slug, + "update-realm"); + } return result.ToResult(realm => Results.Ok(MapToDto(realm))); }) .WithName("Realms_Update") @@ -220,11 +254,26 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // ?hard=true escalates from the reversible soft-delete to the prod-safe hard // delete that DROPs the tenant database (HardDeleteRealmAsync). Default false keeps // the existing soft-delete behaviour. Hard-delete is refused for the control plane. - group.MapDelete("{slug}", async (string slug, IRealmProvisioningService svc, CancellationToken ct, bool hard = false) => + group.MapDelete("{slug}", async ( + string slug, + IRealmProvisioningService svc, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct, + bool hard = false) => { var result = hard ? await svc.HardDeleteRealmAsync(slug, ct) : await svc.DeleteRealmAsync(slug, ct); + if (!result.IsError) + { + RecordControlPlaneRealmOperation( + securityAudit, + http, + slug, + hard ? "hard-delete-realm" : "deactivate-realm", + writeTargetRealm: !hard); + } return result.IsError ? result.ToResult() : Results.NoContent(); }) .WithName("Realms_Delete") @@ -237,11 +286,20 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // realm back (hard-delete). Returns the created slug + primary domain + the // plaintext secrets of any confidential clients (only available at create time). group.MapPost("import", async ( - RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct) => + RealmManifest manifest, + RealmManifestApplier applier, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct) => { var result = await applier.ImportNewRealmAsync(manifest, ct); if (result.IsError) return ManifestError(result.Errors); ModgudMeters.RecordRealmProvisioned(); + RecordControlPlaneRealmOperation( + securityAudit, + http, + result.Value.Slug, + "import-realm"); return Results.Created($"{path}/admin/realms/{result.Value.Slug}", result.Value); }) .WithName("Realms_Import") @@ -253,7 +311,13 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, // ?prune=true makes it a full sync that also deletes the absent entities (k8s // apply --prune — infrastructure + every realm:admin path are protected, never pruned). group.MapPost("{slug}/apply", async ( - string slug, RealmManifest manifest, RealmManifestApplier applier, CancellationToken ct, bool prune = false) => + string slug, + RealmManifest manifest, + RealmManifestApplier applier, + HttpContext http, + ISecurityAuditLog securityAudit, + CancellationToken ct, + bool prune = false) => { if (!string.Equals(slug, manifest.Realm.Slug, StringComparison.Ordinal)) return Results.BadRequest(new @@ -263,6 +327,14 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, }); var result = await applier.UpdateRealmAsync(manifest, prune, ct); + if (!result.IsError) + { + RecordControlPlaneRealmOperation( + securityAudit, + http, + slug, + prune ? "apply-manifest-prune" : "apply-manifest"); + } return result.IsError ? ManifestError(result.Errors) : Results.Ok(result.Value); }) .WithName("Realms_Apply") @@ -308,6 +380,8 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, string slug, IRealmProvisioningService svc, IServiceProvider sp, + HttpContext http, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var target = await svc.GetRealmBySlugAsync(slug, ct); @@ -330,6 +404,14 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, } var result = await svc.TransferControlPlaneAsync(slug, ct); + if (!result.IsError) + { + RecordControlPlaneRealmOperation( + securityAudit, + http, + slug, + "transfer-control-plane"); + } return result.ToResult(realm => Results.Ok(MapToDto(realm))); }) .WithName("Realms_TransferControlPlane") @@ -369,6 +451,46 @@ private static async Task TargetHasUsableAdminAsync( } } + private static void RecordControlPlaneRealmOperation( + ISecurityAuditLog securityAudit, + HttpContext http, + string targetRealmSlug, + string operationCode, + string outcomeCode = AuditOutcomes.Succeeded, + string? reasonCode = null, + bool writeTargetRealm = true) + { + var actorRealmSlug = TenantContext.Current; + var correlationId = Activity.Current?.TraceId.ToString() ?? http.TraceIdentifier; + + securityAudit.Record(new SecurityAuditRecord + { + EventType = AuditEvents.ControlPlaneRealmOperation, + RealmSlug = actorRealmSlug, + TargetRealmSlug = targetRealmSlug, + OutcomeCode = outcomeCode, + ReasonCode = reasonCode, + OperationCode = operationCode, + CorrelationId = correlationId, + }); + + if (!writeTargetRealm || + string.Equals(actorRealmSlug, targetRealmSlug, StringComparison.OrdinalIgnoreCase)) + return; + + securityAudit.Record(new SecurityAuditRecord + { + EventType = AuditEvents.ControlPlaneRealmOperation, + RealmSlug = targetRealmSlug, + CaptureRequestContext = false, + ActorKind = AuditActorKind.ControlPlane, + OutcomeCode = outcomeCode, + ReasonCode = reasonCode, + OperationCode = operationCode, + CorrelationId = correlationId, + }); + } + // Renders a RealmManifestApplier ErrorOr error with the code in the body — the manifest // codes (Realm.AlreadyExists / Realm.NotFound / Manifest.*) are how a test-kit / caller // distinguishes outcomes, so don't collapse them through the shared ToResult. diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs index 4ccf7aae..e36f49a1 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs @@ -162,12 +162,11 @@ private static async Task RegisterAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.DcrClientRegistered, - Level = "Info", - Actor = created.DisplayName, - Ip = sourceIp, - Status = "registered", - Reason = $"clientId {created.ClientId}", - Message = $"DCR client registered: {created.DisplayName ?? "(none)"} ({created.ClientId})", + ActorKind = AuditActorKind.OAuthClient, + OAuthClientId = created.ClientId, + IpAddress = sourceIp, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "register", }); ModgudMeters.RecordDcrRegistration(ModgudMeters.DcrOutcome.Success); @@ -225,11 +224,11 @@ private static void LogRejected(ISecurityAuditLog securityAudit, string ip, stri securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.DcrRegistrationRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"{reason} clientName={clientName ?? "(none)"}", - Message = $"DCR registration rejected: {reason}", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.OAuthClient, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = reason.ToString(), }); } @@ -238,11 +237,12 @@ private static void LogRateLimit(ISecurityAuditLog securityAudit, string ip, Dcr securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RateLimitTriggered, - Level = "Warning", - Ip = ip, - Status = "rate_limited", - Reason = reason.ToString(), - Message = $"DCR rate limit triggered: {reason}", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = reason.ToString(), + OperationCode = "dcr-rate-limit", }); } } diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 929ad7c8..5bb0e0ef 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -936,11 +936,8 @@ ReferenceSyncRegistration.RegisterAll(opts, typeof(Program).Assembly); }); - // Streamless security/ops audit store (logging/audit redesign Track A, Phase 3). - // Typed best-effort sink (bounded channel) + background writer to the system DB. - // Replaced the legacy "Auth:"-message-prefix Serilog sink (AuthLogSink + - // AuthLogPersistenceService, now deleted). The realm is captured from - // TenantContext.Current at emit; the retention prune is a Quartz job (below). + // Structured best-effort security-event sink. Realm events are routed to the + // owning physical realm DB; PII-free deployment events go to the Global Store. builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService()); @@ -990,11 +987,17 @@ defaultCron: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.SystemJobRunHistoryRetentionJob.Description, getParameterSchema: Modgud.Api.Features.Admin.Jobs.JobRunHistoryRetentionJob.GetParameterSchema); - builder.Services.AddSystemJob( + builder.Services.AddRealmJob( key: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Key, name: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Name, defaultCron: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.DefaultCron, description: Modgud.Api.Features.Admin.Jobs.SecurityAuditPruneJob.Description); + builder.Services.AddSystemJob( + key: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Key, + name: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Name, + defaultCron: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.DefaultCron, + description: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.Description, + getParameterSchema: Modgud.Api.Features.Admin.Jobs.PlatformAuditPruneJob.GetParameterSchema); // Inbox — per-recipient notifications with SignalR live push. Both // services are scoped (tenant-aware IDocumentSession). The InboxHub @@ -1486,7 +1489,7 @@ await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( .Where(g => !g.IsDeleted).Take(1).ToListAsync(); await session.Query() .Where(p => !p.IsDeleted).Take(1).ToListAsync(); - await session.Query() + await session.Query() .OrderByDescending(l => l.Timestamp).Take(1).ToListAsync(); await session.Query() .Take(1).ToListAsync(); @@ -1546,12 +1549,12 @@ await Modgud.Infrastructure.Authorization.AppRealmSeeder.SeedAsync( var exitCode = await Modgud.Authentication.Api.Admin.RecoveryCli.RunAsync( app.Services, cliArgs[1..], conf, app.Environment); - // This path never starts the host, so the SecurityAuditWriter background - // drain never runs — flush the recovery CLI's enqueued security-audit - // records to the system DB synchronously before the process exits, or the - // break-glass forensic trail would be lost. + // This path never starts the hosted writer, so route the queued realm and + // platform records synchronously before the process exits. await app.Services.GetRequiredService() - .FlushAsync(app.Services.GetRequiredService()); + .FlushAsync( + app.Services.GetRequiredService(), + app.Services.GetRequiredService()); if (fromEnv) { diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs index dfca6633..26257971 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/AuditSettingsDtos.cs @@ -7,6 +7,7 @@ namespace Modgud.Application.DTOs.RealmSettings; public record AuditSettingsDto { public int VisibilityWindowDays { get; init; } = 90; + public int SecurityRetentionDays { get; init; } = 7; } /// Patch payload for the tenant-audit sub-section. Nullable = no change on @@ -15,4 +16,5 @@ public record AuditSettingsDto public record UpdateAuditSettingsDto { public int? VisibilityWindowDays { get; init; } + public int? SecurityRetentionDays { get; init; } } diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs index 30b4107c..cb3f040d 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs @@ -110,12 +110,14 @@ public static WebApplication MapAccountEndpoints(this WebApplication application securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, - Level = "Warning", - Actor = LogPiiMasking.MaskUsername(request.UserName), - Ip = ip, - Status = "rejected", - Reason = "user not found or inactive", - Message = $"Login failed for {LogPiiMasking.MaskUsername(request.UserName)} — user not found or inactive", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = user?.Id, + UnknownIdentifier = user is null ? request.UserName : null, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.Password, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = user is null ? "user-not-found" : "user-inactive", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Failure); return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401); @@ -139,12 +141,12 @@ public static WebApplication MapAccountEndpoints(this WebApplication application securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.LoginFailed, - Level = "Warning", - Actor = LogPiiMasking.MaskUsername(request.UserName), - Ip = ip, - Status = "rejected", - Reason = "email not verified", - Message = $"Login blocked for {LogPiiMasking.MaskUsername(request.UserName)} — email not verified (realm requires verification)", + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.Password, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-not-verified", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Password, ModgudMeters.LoginOutcome.Failure); return Results.Json(new diff --git a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs index cbb2a8a1..5de219c2 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs @@ -53,11 +53,11 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.BootstrapInviteRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "invalid or expired invite", - Message = "Bootstrap invite consume rejected", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + IpAddress = ip, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-or-expired-invite", }); return Results.Problem( statusCode: StatusCodes.Status400BadRequest, diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs index 97e76148..f936b1a4 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs @@ -191,11 +191,13 @@ await emailService.SendTemplatedEmailAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.MagicLinkInvalid, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "invalid or expired token", - Message = "Magic-link login failed — invalid or expired token", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = request.UserId, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.MagicLink, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "invalid-or-expired-token", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Failure); if (challenge is not null) { session.Delete(challenge); await session.SaveChangesAsync(); } @@ -214,11 +216,13 @@ await emailService.SendTemplatedEmailAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = "user not found or inactive", - Message = "Magic-link login failed — user not found or inactive", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + TargetSubjectId = request.UserId, + IpAddress = ip, + AuthenticationMethod = ModgudMeters.LoginMethod.MagicLink, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "user-not-found-or-inactive", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.MagicLink, ModgudMeters.LoginOutcome.Failure); session.Delete(challenge); diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs index 588711f5..c2718148 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AuditEndpoints.cs @@ -10,13 +10,11 @@ namespace Modgud.Authentication.Api.Admin; /// /// Tenant audit read surface (logging/audit redesign Track A — the GDPR-audit half). /// -/// Unlike the legacy AuthLog (cross-realm in the system DB, scoped at -/// read via ScopeToCallerRealm), lives -/// per-realm in the tenant DB. So the tenant-scoped +/// Like the realm security log, lives +/// per-realm in the tenant DB. The tenant-scoped /// returns only the caller's realm by physical isolation — no /// WHERE Realm = filter is needed and a filter bug cannot leak cross-realm. -/// Control-plane cross-realm fan-out across realm DBs is deferred; the platform-wide -/// surface is the streamless security store (Phase 3). +/// The separate Platform log contains only PII-free deployment events. /// public static class AuditEndpoints { diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs index 7291e91d..e0715f7d 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs @@ -1,50 +1,42 @@ -using System.Security.Claims; using Marten; -using Microsoft.AspNetCore.Http; +using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Marten.Projections.Users; using Modgud.Infrastructure.Persistence.Tenancy; using Modgud.Infrastructure.Realms; namespace Modgud.Authentication.Api.Admin; /// -/// Admin Security log surface (logging/audit redesign Track A — the streamless -/// half). Reads the typed store: unknown-actor login -/// attempts, probes, rate-limits, policy rejections, and operational actions. Entries -/// live cross-realm in the system DB but are attributed to a realm via -/// . -/// -/// The read/clear scope by the CALLER'S realm so a tenant realm-admin sees and -/// clears only their own realm's tenant-visible events; the control-plane -/// realm (per TenantInfo.IsControlPlane, not a hard-coded "system" slug) sees and -/// clears the full cross-realm log including control-plane-only operational rows -/// (). This carries PR #50's scoping forward -/// and extends it with the platform-only visibility gate. -/// -/// The HTTP surface (route, shape) is carried forward from the legacy AuthLog so -/// the SPA keeps working; the backing store changed from the flat AuthLogDocument to the -/// typed SecurityAuditEntry. +/// Two deliberately separate log surfaces: +/// - /auth-log reads only the caller realm's physical database. +/// - /platform-audit reads only the PII-free Global Store and is Control-Plane only. +/// Neither surface offers arbitrary deletion; retention jobs are the only delete path. /// public static class AuthLogEndpoints { public static WebApplication MapAuthLogEndpoints(this WebApplication application, string path) + { + MapRealmSecurityLog(application, path); + MapPlatformAuditLog(application, path); + return application; + } + + private static void MapRealmSecurityLog(WebApplication application, string path) { var group = application.MapGroup($"{path}/admin/auth-log") .WithTags("Admin Security Log") .RequireAuthorization(); group.MapGet("", async ( - IDocumentStore store, - HttpContext http, + IDocumentSession session, string? category, string? eventType, - int? limit) => + int? limit, + CancellationToken ct) => { - await using var session = store.QuerySession(TenantConstants.SystemTenantId); - - var query = ScopeToCallerRealm( - session.Query(), TenantContext.Current, IsControlPlane(http)); + IQueryable query = session.Query(); if (!string.IsNullOrWhiteSpace(category)) query = query.Where(x => x.Category == category); if (!string.IsNullOrWhiteSpace(eventType)) @@ -53,89 +45,260 @@ public static WebApplication MapAuthLogEndpoints(this WebApplication application var rows = await query .OrderByDescending(x => x.Timestamp) .Take(Math.Clamp(limit ?? 200, 1, 1000)) - .ToListAsync(); + .ToListAsync(ct); + + var subjectIds = rows + .SelectMany(x => new[] { x.ActorSubjectId, x.TargetSubjectId }) + .Where(x => x.HasValue) + .Select(x => x!.Value) + .Distinct() + .ToArray(); - // Carry-forward DTO: the legacy grid columns (Timestamp/Level/Message/ - // UserName/Ip/Realm) keep their names — Actor maps to UserName — plus the - // new EventType/Category for taxonomy-chip filtering and Status/Reason. - var dtos = rows.Select(r => new SecurityLogEntryDto( - r.Timestamp, r.Realm, r.Category, r.EventType, r.Level, - r.Actor, r.Ip, r.Status, r.Reason, r.Message)); + var users = subjectIds.Length == 0 + ? [] + : await session.Query() + .Where(x => subjectIds.Contains(x.Id)) + .ToListAsync(ct); + var names = users.ToDictionary(x => x.Id, x => x.GetDisplayLabel()); - return Results.Ok(dtos); + return Results.Ok(rows.Select(row => RealmSecurityLogDto.From(row, names))); }) .WithName("AdminAuthLog_Get") .RequiresPermission("auth-log:read"); + } - // Clearing the security log is destructive — gate behind the global app:admin - // bypass. Scoped to the caller's realm; the control-plane realm wipes the full - // log. The clear is itself audited (audit-of-the-audit): a typed - // audit.log_cleared record naming the operator is emitted AFTER the wipe, so it - // survives as the forensic trail of who cleared what, when. - group.MapDelete("", async ( - IDocumentStore store, - HttpContext http, - ClaimsPrincipal user, - ISecurityAuditLog securityAudit) => + private static void MapPlatformAuditLog(WebApplication application, string path) + { + var group = application.MapGroup($"{path}/admin/platform-audit") + .WithTags("Platform Audit Log") + .RequireAuthorization() + .AddEndpointFilter(); + + group.MapGet("", async ( + IGlobalStore store, + string? category, + string? eventType, + int? limit, + CancellationToken ct) => { - var callerRealm = TenantContext.Current; - var isControlPlane = IsControlPlane(http); - - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - if (isControlPlane) - session.DeleteWhere(x => true); - else - session.DeleteWhere(x => x.Realm == callerRealm); - await session.SaveChangesAsync(); - - var operatorName = user.Identity?.Name ?? "(unknown)"; - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.AuditLogCleared, - Level = "Warning", - Actor = operatorName, - Status = "cleared", - Reason = isControlPlane ? "all realms (control-plane)" : $"realm {callerRealm}", - Message = $"Security log cleared by {operatorName}", - }); - - return Results.Ok(new { Message = "Security log cleared" }); + await using var session = store.QuerySession(); + IQueryable query = session.Query(); + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(x => x.Category == category); + if (!string.IsNullOrWhiteSpace(eventType)) + query = query.Where(x => x.EventType == eventType); + + var rows = await query + .OrderByDescending(x => x.Timestamp) + .Take(Math.Clamp(limit ?? 200, 1, 1000)) + .ToListAsync(ct); + + return Results.Ok(rows.Select(PlatformAuditLogDto.From)); }) - .WithName("AdminAuthLog_Clear") - .RequiresPermission("realm:admin"); + .WithName("AdminPlatformAudit_Get") + .RequiresPermission("platform-audit:read", AppSlugs.ControlPlane); + } +} - return application; +public sealed record RealmSecurityLogDto( + Guid Id, + DateTimeOffset Timestamp, + string Category, + string EventType, + string Severity, + string ActorKind, + string Actor, + string? Target, + string? IpAddress, + string? UserAgent, + string? OAuthClientId, + Guid? ApplicationId, + Guid? SessionId, + Guid? LoginProviderId, + string? AuthenticationMethod, + string? CorrelationId, + string OutcomeCode, + string? ReasonCode, + string? OperationCode, + string? TargetRealmSlug, + string? KeyId, + int? Count, + int? RelatedCount, + int? RetentionDays, + DateTimeOffset? EffectiveAt, + string Message) +{ + internal static RealmSecurityLogDto From( + RealmSecurityAuditEvent row, + IReadOnlyDictionary names) + => new( + row.Id, + row.Timestamp, + row.Category, + row.EventType, + row.Severity.ToString(), + row.ActorKind.ToString(), + RenderActor(row, names), + RenderTarget(row.TargetSubjectId, names), + row.IpAddress, + row.UserAgent, + row.OAuthClientId, + row.ApplicationId, + row.SessionId, + row.LoginProviderId, + row.AuthenticationMethod, + row.CorrelationId, + row.OutcomeCode, + row.ReasonCode, + row.OperationCode, + row.TargetRealmSlug, + row.KeyId, + row.Count, + row.RelatedCount, + row.RetentionDays, + row.EffectiveAt, + AuditEventRenderer.Render(row)); + + private static string RenderActor( + RealmSecurityAuditEvent row, + IReadOnlyDictionary names) + { + if (row.ActorSubjectId is { } subject) + return names.TryGetValue(subject, out var name) ? name : "Deleted user"; + if (row.UnknownIdentifierFingerprint is { Length: > 0 } fingerprint) + return $"Unknown identifier · {fingerprint[..Math.Min(10, fingerprint.Length)]}"; + if (!string.IsNullOrWhiteSpace(row.OAuthClientId)) + return row.OAuthClientId; + + return row.ActorKind switch + { + AuditActorKind.ControlPlane => "Control Plane", + AuditActorKind.System => "System", + AuditActorKind.ServiceAccount => "Service account", + AuditActorKind.OAuthClient => "OAuth client", + _ => row.ActorKind.ToString(), + }; } - private static bool IsControlPlane(HttpContext http) => - http.Items[TenantConstants.HttpContextTenantInfoKey] is TenantInfo info && info.IsControlPlane; - - /// - /// Realm-scopes a security-log query: the control-plane realm sees the full - /// cross-realm log (including control-plane-only operational rows); every other - /// realm sees only its own realm's tenant-visible entries - /// (!PlatformOnly). Pure + provider-agnostic so it composes over either - /// Marten's IQueryable or an in-memory one (used by the unit tests). - /// - public static IQueryable ScopeToCallerRealm( - IQueryable query, string callerRealm, bool callerIsControlPlane) - => callerIsControlPlane - ? query - : query.Where(x => x.Realm == callerRealm && !x.PlatformOnly); + private static string? RenderTarget(Guid? subject, IReadOnlyDictionary names) + => subject is null + ? null + : names.TryGetValue(subject.Value, out var name) ? name : "Deleted user"; } -/// Read DTO for the Security log grid. Carries the legacy column names -/// ( = the entry's Actor) so the existing SPA keeps -/// working, plus the typed / for chip -/// filtering and / detail. -public sealed record SecurityLogEntryDto( +public sealed record PlatformAuditLogDto( + Guid Id, DateTimeOffset Timestamp, - string? Realm, string Category, string EventType, - string Level, - string? UserName, - string? Ip, - string? Status, - string? Reason, - string Message); + string Severity, + string OutcomeCode, + string? ReasonCode, + string? OperationCode, + string? TargetRealmSlug, + string? CorrelationId, + int? Count, + int? RelatedCount, + string Message) +{ + internal static PlatformAuditLogDto From(PlatformAuditEvent row) + => new( + row.Id, + row.Timestamp, + row.Category, + row.EventType, + row.Severity.ToString(), + row.OutcomeCode, + row.ReasonCode, + row.OperationCode, + row.TargetRealmSlug, + row.CorrelationId, + row.Count, + row.RelatedCount, + AuditEventRenderer.Render(row)); +} + +internal static class AuditEventRenderer +{ + public static string Render(RealmSecurityAuditEvent row) + { + var details = new List(); + Add(details, "reason", row.ReasonCode); + Add(details, "operation", row.OperationCode); + Add(details, "target-realm", row.TargetRealmSlug); + Add(details, "client", row.OAuthClientId); + Add(details, "key", row.KeyId); + Add(details, "count", row.Count); + Add(details, "related", row.RelatedCount); + Add(details, "retention-days", row.RetentionDays); + Add(details, "reminded", row.RemindedCount); + Add(details, "self-erased", row.SelfErasedCount); + Add(details, "auto-purged", row.AutoPurgedCount); + Add(details, "invite-codes-pruned", row.InviteCodesPrunedCount); + Add(details, "reused", row.ReusedCount); + Add(details, "effective-at", row.EffectiveAt); + return Compose(row.EventType, row.OutcomeCode, details); + } + + public static string Render(PlatformAuditEvent row) + { + var details = new List(); + Add(details, "reason", row.ReasonCode); + Add(details, "operation", row.OperationCode); + Add(details, "realm", row.TargetRealmSlug); + Add(details, "domain", row.Domain); + Add(details, "previous-domain", row.PreviousDomain); + Add(details, "count", row.Count); + Add(details, "related", row.RelatedCount); + Add(details, "retention-days", row.RetentionDays); + Add(details, "effective-at", row.EffectiveAt); + return Compose(row.EventType, row.OutcomeCode, details); + } + + private static string Compose( + string eventType, + string outcome, + IReadOnlyCollection details) + { + var occurrence = eventType switch + { + AuditEvents.LoginFailedUnknownUser => "Login for an unknown identifier", + AuditEvents.MagicLinkInvalid => "Invalid or expired magic link", + AuditEvents.ExternalLoginRejected => "External login", + AuditEvents.SamlSignatureRejected => "SAML signature validation", + AuditEvents.IdentityHijackBlocked => "External identity takeover attempt", + AuditEvents.JitEmailConflict => "JIT email conflict", + AuditEvents.PrivilegeEscalationBlocked => "Federated privilege escalation", + AuditEvents.RateLimitTriggered => "Rate limit", + AuditEvents.RefreshTokenReuseDetected => "Refresh-token reuse", + AuditEvents.DcrRegistrationRejected => "Dynamic client registration", + AuditEvents.BootstrapInviteRejected => "Bootstrap invite", + AuditEvents.SecurityRetentionChanged => "Security-log retention", + AuditEvents.SigningKeyRotated => "Signing key rotation", + AuditEvents.SigningKeyPurged => "Signing-key cleanup", + AuditEvents.SamlCertRotated => "SAML certificate rotation", + AuditEvents.SamlMetadataRefreshed => "SAML metadata refresh", + AuditEvents.RecoveryCliInvoked => "Recovery CLI operation", + AuditEvents.RealmProvisioned => "Realm provisioning", + AuditEvents.RealmAdopted => "Realm adoption", + AuditEvents.ControlPlaneTransferred => "Control-Plane transfer", + AuditEvents.ControlPlaneRealmOperation => "Control-Plane realm operation", + AuditEvents.AccountLifecycleSwept => "Account lifecycle sweep", + AuditEvents.BootstrapInviteIssued => "Bootstrap invite issuance", + AuditEvents.DcrClientRegistered => "Dynamic client registration", + AuditEvents.DcrClientFirstUsed => "Dynamic client first use", + AuditEvents.DcrClientGarbageCollected => "Dynamic client cleanup", + _ => eventType, + }; + + return details.Count == 0 + ? $"{occurrence}: {outcome}" + : $"{occurrence}: {outcome} ({string.Join(", ", details)})"; + } + + private static void Add(List details, string key, object? value) + { + if (value is not null && !string.IsNullOrWhiteSpace(value.ToString())) + details.Add($"{key}={value}"); + } +} diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs index effb6a96..bd7ad3cf 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs @@ -1,4 +1,3 @@ -using System.Security.Claims; using Modgud.Application.DTOs.RealmSettings; using Modgud.Authentication.RealmSettings; using Modgud.Authorization.AspNetCore; @@ -45,9 +44,22 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, group.MapPatch("", async ( UpdateRealmSettingsDto dto, IRealmSettingsService svc, + ISecurityAuditLog securityAudit, CancellationToken ct) => { var result = await svc.PatchAsync(dto, ct); + if (!result.IsError && dto.Audit?.SecurityRetentionDays is { } retentionDays) + { + securityAudit.Record(new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "change-retention", + RetentionDays = retentionDays, + }); + } + return result.Match( ok => Results.Ok(ok), errors => Results.Problem( @@ -67,7 +79,6 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, // realm-settings:write permission as the rest of this surface. group.MapPost("rotate-signing-key", async ( IRealmKeyStore keyStore, - ClaimsPrincipal user, ISecurityAuditLog securityAudit, CancellationToken ct) => { @@ -75,16 +86,13 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, var creds = await keyStore.RotateAsync(slug, ct); var kid = creds.Key.KeyId; - var userName = user.Identity?.Name ?? "(unknown)"; - // Request context — leave Realm unset (ambient TenantContext is correct). securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SigningKeyRotated, - Level = "Warning", - Actor = userName, - Status = "rotated", - Reason = $"kid {kid}", - Message = $"signing key rotated by {userName} — new kid {kid}", + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate", + KeyId = kid, }); return Results.Ok(new RotateSigningKeyResponseDto(kid)); diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs index da4221cb..2492a253 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs @@ -127,12 +127,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"reset-2fa: UserId={user.Id} TOTP={wasTotpEnabled} EmailOtp={wasEmailOtpEnabled} PasskeysDeleted={passkeys.Count}", - Message = $"Recovery reset-2fa. UserId={user.Id} TOTP={wasTotpEnabled} EmailOtp={wasEmailOtpEnabled} PasskeysDeleted={passkeys.Count}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "reset-2fa", + ReasonCode = $"totp:{wasTotpEnabled};email-otp:{wasEmailOtpEnabled}", + Count = passkeys.Count, }); ctx.WriteLine($"✓ 2FA reset for {user.UserName}:"); @@ -200,12 +202,12 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"set-email: UserId={user.Id} Old={LogPiiMasking.MaskEmail(oldEmail)} New={LogPiiMasking.MaskEmail(newEmail)}", - Message = $"Recovery set-email. UserId={user.Id} Old={LogPiiMasking.MaskEmail(oldEmail)} New={LogPiiMasking.MaskEmail(newEmail)}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "set-email", }); ctx.WriteLine($"✓ Email updated for {user.UserName}:"); @@ -271,12 +273,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.Services.GetRequiredService().Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = user.Id.ToString(), - Status = "succeeded", - Reason = $"magic-link: UserId={user.Id} ExpiresAt={challenge.ExpiresAt:O}", - Message = $"Recovery magic-link generated. UserId={user.Id} ExpiresAt={challenge.ExpiresAt:O}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = user.Id, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "generate-magic-link", + EffectiveAt = challenge.ExpiresAt, }); ctx.WriteLine($"✓ Magic link for {user.UserName} (expires in {expirationMinutes} min):"); @@ -311,11 +314,11 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "initiated", - Reason = "rebuild-projections", - Message = "Recovery rebuild-projections initiated", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "rebuild-projections", }); // MasterTableTenancy disables Marten's default tenant, so the no-arg @@ -334,11 +337,11 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "succeeded", - Reason = "rebuild-projections", - Message = "Recovery rebuild-projections completed", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rebuild-projections", }); return 0; } @@ -385,12 +388,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = LogPiiMasking.MaskUsername(userName), - Status = "failed", - Reason = $"bootstrap-admin: UserName={LogPiiMasking.MaskUsername(userName)} Code={result.FirstError.Code} Detail={result.FirstError.Description}", - Message = $"Recovery bootstrap-admin failed. Realm={ctx.RealmSlug} UserName={LogPiiMasking.MaskUsername(userName)} Code={result.FirstError.Code} Detail={result.FirstError.Description}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + UnknownIdentifier = userName, + OutcomeCode = AuditOutcomes.Failed, + OperationCode = "bootstrap-admin-direct", + ReasonCode = result.FirstError.Code, }); return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); } @@ -399,12 +403,12 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = admin.UserId.ToString(), - Status = "succeeded", - Reason = $"bootstrap-admin: UserId={admin.UserId} Mode=Direct", - Message = $"Recovery bootstrap-admin succeeded. Realm={ctx.RealmSlug} UserId={admin.UserId} Mode=Direct", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + TargetSubjectId = admin.UserId, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "bootstrap-admin-direct", }); ctx.WriteLine($"✓ Admin created in realm '{ctx.RealmSlug}':"); @@ -437,12 +441,13 @@ private static async Task IssueInviteAsync( ctx.Services.GetRequiredService().Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Actor = LogPiiMasking.MaskUsername(userName), - Status = "initiated", - Reason = $"bootstrap-admin invite: UserName={LogPiiMasking.MaskUsername(userName)} Email={LogPiiMasking.MaskEmail(email)} ExpiresAt={invite.ExpiresAt:O}", - Message = $"Recovery bootstrap-admin issued invite. Realm={ctx.RealmSlug} UserName={LogPiiMasking.MaskUsername(userName)} Email={LogPiiMasking.MaskEmail(email)} ExpiresAt={invite.ExpiresAt:O}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + UnknownIdentifier = email, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "bootstrap-admin-invite", + EffectiveAt = invite.ExpiresAt, }); ctx.WriteLine($"✓ Bootstrap-invite issued for realm '{ctx.RealmSlug}':"); @@ -535,11 +540,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.Services.GetRequiredService().Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "succeeded", - Reason = $"migrate-cc-credentials: Migrated={migrated} SaCreated={saCreated} SaReused={saReused}", - Message = $"Recovery migrate-cc-credentials completed. Realm={ctx.RealmSlug} Migrated={migrated} SaCreated={saCreated} SaReused={saReused}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "migrate-cc-credentials", + Count = migrated, + RelatedCount = saCreated, + ReusedCount = saReused, }); ctx.WriteLine(); @@ -630,14 +638,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Added '{domain}' to realm '{slug}'. Now: [{string.Join(", ", realm.Domains)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-add-domain: Realm={slug} Domain={domain}", - Message = $"Recovery realm-add-domain — Realm={slug} Domain={domain}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-add-domain", + Domain = domain, }); return 0; } @@ -686,14 +694,14 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Removed '{domain}' from realm '{slug}'. Now: [{string.Join(", ", remaining)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-remove-domain: Realm={slug} Domain={domain}", - Message = $"Recovery realm-remove-domain — Realm={slug} Domain={domain}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-remove-domain", + Domain = domain, }); return 0; } @@ -747,14 +755,15 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine(" affected users must re-register their passkeys (other login"); ctx.WriteLine(" methods are unaffected)."); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"realm-set-primary-domain: Realm={slug} Old={oldPrimary} New={domain} (passkeys invalidated)", - Message = $"Recovery realm-set-primary-domain — Realm={slug} Old={oldPrimary} New={domain}. WebAuthn RP changed; existing passkeys invalidated.", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "realm-set-primary-domain", + Domain = domain, + PreviousDomain = oldPrimary, }); return 0; } @@ -801,14 +810,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = targetSlug, - Status = "succeeded", - Reason = $"control-plane transfer: Target={targetSlug}", - Message = $"Recovery control-plane transfer. Target={targetSlug}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = targetSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "control-plane-transfer", }); ctx.WriteLine($"✓ Control plane transferred to realm '{targetSlug}'."); ctx.PrintRestartHint(); @@ -848,14 +856,13 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = slug, - Status = "succeeded", - Reason = $"adopt-tenant: Slug={slug}", - Message = $"Recovery adopt-tenant. Slug={slug}", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "adopt-tenant", }); ctx.WriteLine($"✓ Adopted existing database as realm '{slug}'."); ctx.WriteLine($" Domains: {string.Join(", ", result.Value.Domains)}"); @@ -882,11 +889,12 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.Services.GetRequiredService().Record(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, - Level = "Warning", - Realm = ctx.RealmSlug, - Status = "rotated", - Reason = $"rotate-signing-key: Realm={ctx.RealmSlug} NewKid={kid}", - Message = $"Recovery rotate-signing-key. Realm={ctx.RealmSlug} NewKid={kid}", + Severity = AuditSeverity.Warning, + RealmSlug = ctx.RealmSlug, + ActorKind = AuditActorKind.System, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate-signing-key", + KeyId = kid, }); ctx.WriteLine($" OK new active kid: {kid}"); ctx.WriteLine(" Previous key retired into the 30-day verification overlap window."); diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs index 6e51fabf..6336bb5d 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs @@ -64,10 +64,11 @@ public async Task ProcessAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"misconfigured provider type {config.Type} (LoginProvider {loginProviderId})", - Message = "External login rejected — provider misconfigured (expected Oidc or Saml)", + Severity = AuditSeverity.Warning, + LoginProviderId = loginProviderId, + AuthenticationMethod = "external", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"unsupported-provider-type:{config.Type}", }); return ExternalLoginResult.Failed(err.Code, err.Description); } @@ -84,10 +85,11 @@ public async Task ProcessAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = "missing iss/sub", - Message = "External login rejected — identity provider returned no iss/sub", + Severity = AuditSeverity.Warning, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "missing-issuer-or-subject", }); return ExternalLoginResult.Failed("Idp.InvalidToken", "The identity provider did not return a subject."); } @@ -148,10 +150,13 @@ public async Task ProcessAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, - Level = "Warning", - Status = "rejected", - Reason = "external subject already linked to a different user", - Message = "Link attempt rejected — external subject already linked to a different user", + Severity = AuditSeverity.Warning, + ActorSubjectId = authId, + TargetSubjectId = link.UserId, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "subject-linked-to-different-user", }); return ExternalLoginResult.Failed("Idp.LinkedToOtherUser", "This identity is already linked to another Modgud account."); @@ -205,15 +210,16 @@ public async Task ProcessAsync( var email = scriptResult.Email.Presence == FieldPresence.Value ? scriptResult.Email.Value : null; if (!IsEmailAllowed(config, email)) { - var maskedEmail = LogPiiMasking.MaskEmail(email); securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email not in allowlist", - Message = $"External login rejected — email '{maskedEmail}' not in allowlist", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = email, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-domain-not-allowed", }); return ExternalLoginResult.Failed("Idp.EmailNotAllowed", "Your email domain is not allowed for this provider."); } @@ -239,15 +245,15 @@ public async Task ProcessAsync( // specifically-configured IdP, which is the trust anchor.) if (!IsEmailLinkTrustworthy(config, rawClaims, email)) { - var maskedEmail = LogPiiMasking.MaskEmail(email); securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email-link blocked — IdP did not assert email_verified", - Message = $"External email-link rejected — IdP did not verify email '{maskedEmail}' (TrustForEmailLink requires email_verified for OIDC)", + Severity = AuditSeverity.Warning, + TargetSubjectId = existing.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "email-not-verified-by-provider", }); return ExternalLoginResult.Failed("Idp.EmailNotVerified", "The identity provider did not verify this email address, so it cannot be auto-linked to an existing account."); @@ -279,10 +285,13 @@ public async Task ProcessAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = "no existing link and JIT creation disabled", - Message = "External login rejected — no existing link and automatic user creation is disabled", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = email, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "jit-disabled", }); return ExternalLoginResult.Failed("Idp.NoUserAndAutoCreateOff", "No user is linked to this identity and automatic creation is disabled."); @@ -294,20 +303,21 @@ public async Task ProcessAsync( // Email-uniqueness on JIT: another user already owns this email → reject. var emailUpper = email.ToUpperInvariant(); - var emailTaken = await session.Query() + var emailOwnerId = await session.Query() .Where(p => !p.IsDeleted && p.NormalizedEmail == emailUpper) - .AnyAsync(ct); - if (emailTaken) + .Select(p => p.Id) + .FirstOrDefaultAsync(ct); + if (emailOwnerId != Guid.Empty) { - var maskedEmail = LogPiiMasking.MaskEmail(email); securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email already taken (JIT create)", - Message = $"JIT creation rejected — email '{maskedEmail}' is already taken by another user", + Severity = AuditSeverity.Warning, + TargetSubjectId = emailOwnerId, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "email-owned-by-existing-user", }); return ExternalLoginResult.Failed("Idp.EmailConflict", "A Modgud account with this email already exists. Please contact your administrator."); @@ -358,15 +368,14 @@ public async Task ProcessAsync( .FirstOrDefaultAsync(ct); if (clashingUserId != Guid.Empty) { - var maskedEmail = LogPiiMasking.MaskEmail(newEmail); securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, - Level = "Warning", - Actor = maskedEmail, - Status = "rejected", - Reason = "email already taken (user-update script)", - Message = $"UserUpdateScript email conflict — '{maskedEmail}' is already taken by another user; login rejected", + Severity = AuditSeverity.Warning, + ActorSubjectId = user.Id, + TargetSubjectId = clashingUserId, + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "user-update-email-conflict", }); return new ApplyUpdatesError( "Idp.EmailConflict", @@ -448,11 +457,12 @@ private async Task Success( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Actor = user.Id.ToString(), - Status = "rejected", - Reason = "user inactive or deleted", - Message = $"External login rejected — user {user.Id} is inactive or deleted", + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = user.IsDeleted ? "user-deleted" : "user-inactive", }); return ExternalLoginResult.Failed("Idp.UserInactive", "This account is not active."); } @@ -515,11 +525,13 @@ private async Task Success( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.PrivilegeEscalationBlocked, - Level = "Warning", - Actor = user.Id.ToString(), - Status = "blocked", - Reason = $"dropped {derived.DroppedRealmAdminCount} externally-derived group(s) conferring realm:admin via provider {config.Slug}", - Message = $"Blocked {derived.DroppedRealmAdminCount} externally-derived realm:admin group(s) for user {user.Id}", + Severity = AuditSeverity.Warning, + TargetSubjectId = user.Id, + LoginProviderId = loginProviderId, + AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = "realm-admin-group-derived-externally", + Count = derived.DroppedRealmAdminCount, }); } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs index 07fb67e9..1175f2eb 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs @@ -231,11 +231,13 @@ public async Task RefreshMetadataAsync(Guid loginProviderId, CancellationT securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SamlMetadataRefreshed, - Realm = existing.RealmSlug, - Level = "Info", - Status = "cert_changed", - Reason = $"signing certs {oldCount}->{newCount}", - Message = $"SAML metadata refresh for provider {loginProviderId} changed signing certs ({oldCount} -> {newCount})", + RealmSlug = existing.RealmSlug, + ActorKind = AuditActorKind.System, + LoginProviderId = loginProviderId, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "signing-certificates-changed", + Count = newCount, + RelatedCount = oldCount, }); } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs index 708d20ed..578811dc 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs @@ -60,10 +60,11 @@ public async Task StartLoginAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"SAML: no IdP metadata cached (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — no IdP metadata cached", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "metadata-unavailable", }); return Results.Redirect("/login?error=saml-no-metadata"); } @@ -74,10 +75,11 @@ public async Task StartLoginAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Status = "rejected", - Reason = $"SAML: IdP metadata has no SSO endpoint (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — IdP metadata has no SSO endpoint", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "sso-endpoint-missing", }); return Results.Redirect("/login?error=saml-no-sso"); } @@ -151,11 +153,12 @@ public async Task HandleAcsAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: context build failed (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — context build failed", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "context-build-failed", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect("/login?error=saml-invalid"); @@ -179,11 +182,12 @@ public async Task HandleAcsAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: response read/validate failed (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — response read/validate failed", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = "response-validation-failed", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect("/login?error=saml-invalid"); @@ -194,11 +198,12 @@ public async Task HandleAcsAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: non-success status {saml2Response.Status} (provider {provider.LoginProviderId})", - Message = $"SAML login refused for provider {provider.Slug} — non-success status {saml2Response.Status}", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"saml-status:{saml2Response.Status}", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(saml2Response.Status.ToString() ?? "status")}"); @@ -216,11 +221,12 @@ public async Task HandleAcsAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SamlSignatureRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: required-signature check failed ({sigError}) for provider {provider.LoginProviderId}", - Message = $"SAML response failed required-signature check ({sigError}) for provider {provider.Slug}", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Blocked, + ReasonCode = sigError, }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(sigError)}"); @@ -258,11 +264,12 @@ public async Task HandleAcsAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.ExternalLoginRejected, - Level = "Warning", - Ip = ip, - Status = "rejected", - Reason = $"SAML: request correlation failed ({reason}) for provider {provider.LoginProviderId}", - Message = $"SAML response refused for provider {provider.Slug} — request correlation failed ({reason})", + Severity = AuditSeverity.Warning, + LoginProviderId = provider.LoginProviderId, + IpAddress = ip, + AuthenticationMethod = "saml", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = $"request-correlation:{reason}", }); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{reason}"); diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs index b9e9a605..c20aba04 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs @@ -84,14 +84,14 @@ private async Task TickAsync(CancellationToken ct) if (refreshed > 0 || failed > 0) { - // Platform-wide control-plane tick — leave Realm unset. - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.SamlMetadataRefreshed, - Level = "Info", - Status = "refreshed", - Reason = $"refreshed={refreshed} failed={failed}", - Message = $"SAML metadata refresh tick — refreshed={refreshed} failed={failed} (scanned={snapshot.Count})", + OutcomeCode = failed == 0 ? AuditOutcomes.Succeeded : AuditOutcomes.Completed, + ReasonCode = failed == 0 ? null : "partial-failure", + OperationCode = "refresh-due-providers", + Count = refreshed, + RelatedCount = failed, }); } } diff --git a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs index 1ddaf7f4..9ec968e5 100644 --- a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs +++ b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs @@ -217,11 +217,11 @@ public async Task RotateAsync(CancellationToken ct = default) _securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, - Realm = realmSlug, - Level = "Info", - Status = "rotated", - Reason = $"thumbprint {doc.ActiveCertThumbprint}, notAfter {doc.ActiveCertNotAfter:o}", - Message = $"Rotated SAML SP cert — new thumbprint {doc.ActiveCertThumbprint}, valid until {doc.ActiveCertNotAfter:o}", + RealmSlug = realmSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate", + KeyId = doc.ActiveCertThumbprint, + EffectiveAt = doc.ActiveCertNotAfter, }); return newCert; @@ -289,11 +289,11 @@ private async Task LoadOrCreateAsync(CancellationToke _securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, - Realm = realmSlug, - Level = "Info", - Status = "generated", - Reason = $"initial cert, thumbprint {doc.ActiveCertThumbprint}", - Message = $"Generated initial SAML SP cert — thumbprint {doc.ActiveCertThumbprint}, valid until {doc.ActiveCertNotAfter:o}", + RealmSlug = realmSlug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "generate-initial", + KeyId = doc.ActiveCertThumbprint, + EffectiveAt = doc.ActiveCertNotAfter, }); return doc; diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index 896b78f2..cc5cfbd4 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -376,17 +376,28 @@ internal static DeletionSettingsDto MapDeletionToDto(DeletionSettings? s) private static ErrorOr ApplyAuditPatch(AuditSettings? current, UpdateAuditSettingsDto patch) { var s = current ?? new AuditSettings(); - var merged = s with { VisibilityWindowDays = patch.VisibilityWindowDays ?? s.VisibilityWindowDays }; + var merged = s with + { + VisibilityWindowDays = patch.VisibilityWindowDays ?? s.VisibilityWindowDays, + SecurityRetentionDays = patch.SecurityRetentionDays ?? s.SecurityRetentionDays, + }; if (merged.VisibilityWindowDays < 1) return Error.Validation("Audit.InvalidVisibilityWindowDays", "VisibilityWindowDays must be at least 1."); + if (merged.SecurityRetentionDays is < 1 or > 365) + return Error.Validation("Audit.InvalidSecurityRetentionDays", + "SecurityRetentionDays must be between 1 and 365."); return merged; } internal static AuditSettingsDto MapAuditToDto(AuditSettings? s) { s ??= AuditSettings.Defaults; - return new AuditSettingsDto { VisibilityWindowDays = s.VisibilityWindowDays }; + return new AuditSettingsDto + { + VisibilityWindowDays = s.VisibilityWindowDays, + SecurityRetentionDays = s.SecurityRetentionDays, + }; } internal static DcrSettingsDto MapDcrToDto(DcrSettings? s) diff --git a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs index 67ce7cc3..a09f2d3d 100644 --- a/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs +++ b/src/dotnet/Modgud.Authentication/Setup/MartenStoreOptionsExtensions.cs @@ -181,15 +181,14 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options) .Index(x => x.LoginProviderId) .Index(x => x.IsUnlinked); - // Streamless security/ops store (logging/audit redesign Track A, Phase 3). - // Cross-realm in the system DB; the typed successor to the personal-data- - // bearing-but-streamless portion of AuthLogDocument. Indexed for the admin - // read (Realm scope + EventType chip filter) and the retention prune. - options.Schema.For() + // Realm-owned security event store. The absence of a Realm column is + // deliberate: physical database ownership is the isolation boundary. + options.Schema.For() .Identity(x => x.Id) .Index(x => x.Timestamp) - .Index(x => x.Realm) .Index(x => x.EventType); + options.Schema.For() + .Identity(x => x.Id); // Tenant-scoped singleton config doc. One row per tenant DB, // addressed by the fixed `RealmSettings.SingletonId`. Owned by diff --git a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs index 9453714b..2e809939 100644 --- a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs +++ b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs @@ -171,11 +171,11 @@ await emailService.SendTemplatedEmailAsync( securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.BootstrapInviteIssued, - Level = "Info", - Actor = LogPiiMasking.MaskEmail(normalizedEmail), - Status = "issued", - Reason = $"expires {invite.ExpiresAt}, issued by {issuedBy ?? "(self/CLI)"}", - Message = "Bootstrap invite issued", + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = normalizedEmail, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "issue", + EffectiveAt = invite.ExpiresAt, }); return new IssuedInvite(invite.Id, token, url, invite.ExpiresAt, normalizedEmail, normalizedUserName); diff --git a/src/dotnet/Modgud.Authorization/README.md b/src/dotnet/Modgud.Authorization/README.md index 84183bc4..60e835cc 100644 --- a/src/dotnet/Modgud.Authorization/README.md +++ b/src/dotnet/Modgud.Authorization/README.md @@ -248,8 +248,8 @@ Treat that as inspiration, not a drop-in. - **User profile management** — display fields, change-requests, profile self-service. Lives in `Modgud.Api/Features/Account` + `Admin`. - **Auth log / audit** — the `AuthAuditView` projection (GDPR-audit) and the - `SecurityAuditEntry` streamless security store are Modgud-internal, not part - of the slice. + realm-owned `RealmSecurityAuditEvent` plus PII-free global + `PlatformAuditEvent` stores are Modgud-internal, not part of the slice. - **Frontend** — see Step 6 above. The split is intentional: this slice owns "**who has what permission, who's diff --git a/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs b/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs index 9e2ba3e1..dc2e7b7a 100644 --- a/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs +++ b/src/dotnet/Modgud.Domain/Realms/AuditSettings.cs @@ -6,7 +6,11 @@ namespace Modgud.Domain.Realms; /// no migration). Null on the parent = never configured; callers read it as /// . /// -/// Visibility window, NOT retention/deletion. The audit trail is a +/// The event-sourced audit trail uses a visibility window (not deletion). +/// The separate realm security-event store has a real hard-retention setting. +/// Both policies are realm-owned. +/// +/// The audit trail is a /// rebuildable projection (AuthAuditView) over event streams we keep for the /// aggregate's lifetime (masked on erase). This window only bounds what the read /// surface *shows* — it does not delete history. Named VisibilityWindowDays @@ -19,6 +23,10 @@ public record AuditSettings /// are hidden from the view (not deleted). Must be at least 1. public int VisibilityWindowDays { get; init; } = 90; + /// Hard retention for structured realm security events. Valid range + /// is 1..365 days. Defaults to seven days. + public int SecurityRetentionDays { get; init; } = 7; + /// Shared defaults used when a realm has never configured the audit /// window. Matches the property initializer above. public static AuditSettings Defaults { get; } = new(); diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs index eee8ef55..871682e0 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditCategories.cs @@ -21,14 +21,14 @@ public static class AuditCategories public const string AdminRealm = "admin-realm"; public const string DcrOAuth = "dcr-oauth"; - // ── Streamless (Track A — the security/ops store, SecurityAuditEntry) ── + // ── Streamless realm/platform security events ── /// Tenant-relevant security threats with no aggregate stream: /// unknown-actor login attempts, probes, rate-limit hits, policy rejections, /// and the audit-of-the-audit records. public const string SecurityOps = "security-ops"; /// Operational actions (key/cert rotation, recovery-CLI, realm - /// provisioning, sweeps). Some are tenant-visible, the cross-realm infra ones - /// are control-plane-only — see . + /// provisioning, sweeps). Storage scope is explicit at the call site through + /// either a realm or platform record type. public const string Operations = "operations"; } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs index 1fa8fa6d..27009891 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs @@ -10,10 +10,9 @@ namespace Modgud.Infrastructure.Audit; /// federation.* / admin.* codes name occurrences on the user- and config- /// aggregate streams (projected into the per-realm GDPR-audit view). The /// security.* / ops.* / audit.* codes name streamless occurrences -/// (no aggregate to attach to) routed to the cross-realm -/// SecurityAuditEntry store under a legitimate-interest basis with short -/// retention. The boundary is about whether a stream exists, not whether the data -/// is personal — see the maintainers' logging-audit-redesign design note. +/// routed either to the owning realm's RealmSecurityAuditEvent store or, +/// for genuinely deployment-wide work, to the PII-free Global Store +/// PlatformAuditEvent. /// /// PII discipline: these name occurrences, not payloads. The /// stream-backed rows store only metadata (who/when/what-kind/realm) and inherit @@ -69,7 +68,7 @@ public static class AuditEvents public const string LoginProviderDeleted = "admin.login_provider_deleted"; // ───────────────────────────────────────────────────────────────────── - // Streamless (Track A — SecurityAuditEntry, legitimate interest + retention) + // Streamless realm/platform security events // ───────────────────────────────────────────────────────────────────── // ── Security: streamless threats (tenant-visible) ──────────────── @@ -126,13 +125,12 @@ public static class AuditEvents public const string BootstrapInviteRejected = "security.bootstrap_invite_rejected"; // ── Audit-of-the-audit (tenant-visible) ────────────────────────── - /// The audit/security log was cleared by an operator. Records WHO + - /// when + realm — a forensic record of the destructive action itself. - public const string AuditLogCleared = "audit.log_cleared"; - /// The audit/security log was exported by an operator. public const string AuditLogExported = "audit.log_exported"; + /// A realm admin changed the hard retention of realm security events. + public const string SecurityRetentionChanged = "audit.security_retention_changed"; + // ── Operations: realm/platform actions ─────────────────────────── /// A realm signing key was rotated by an admin (tenant-visible). public const string SigningKeyRotated = "ops.signing_key_rotated"; @@ -164,6 +162,9 @@ public static class AuditEvents /// (platform-only). public const string ControlPlaneTransferred = "ops.control_plane_transferred"; + /// A Control-Plane actor changed one explicitly selected realm. + public const string ControlPlaneRealmOperation = "ops.control_plane_realm_operation"; + /// A per-realm account-lifecycle sweep ran (reminders / self-erase / /// auto-purge counts). Platform-only operational summary. public const string AccountLifecycleSwept = "ops.account_lifecycle_swept"; @@ -183,9 +184,8 @@ public static class AuditEvents public const string DcrClientGarbageCollected = "ops.dcr_client_garbage_collected"; // ───────────────────────────────────────────────────────────────────── - // Routing helpers (the taxonomy is the source of truth for category + - // visibility, so a call site passes only the EventType — it cannot mark a - // platform-only event tenant-visible by mistake). + // Taxonomy helper. Store ownership is selected through separate realm and + // platform record types, never inferred from this event code. // ───────────────────────────────────────────────────────────────────── /// The code an event type belongs to, @@ -201,24 +201,4 @@ _ when eventType.StartsWith("admin.", StringComparison.Ordinal) => AuditCategori _ => AuditCategories.Authentication, // auth.* }; - /// - /// Streamless event types that are control-plane-only — cross-realm infra - /// or platform operations a tenant realm-admin must NOT see. Everything else in - /// the streamless store is tenant-visible (a realm-admin sees their own realm's - /// rows). The read endpoint filters on the resolved flag stored on each row. - /// - private static readonly HashSet PlatformOnlyEvents = - [ - SigningKeyPurged, - SamlMetadataRefreshed, - RecoveryCliInvoked, - RealmProvisioned, - RealmAdopted, - ControlPlaneTransferred, - AccountLifecycleSwept, - ]; - - /// True if the event type is control-plane-only (see - /// ). - public static bool IsPlatformOnly(string eventType) => PlatformOnlyEvents.Contains(eventType); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs index 87ae3d87..52248778 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs @@ -1,69 +1,77 @@ namespace Modgud.Infrastructure.Audit; /// -/// One streamless security/ops occurrence to record. The caller supplies the -/// code plus whatever context it has; the sink derives -/// the Category + control-plane visibility from the code (the taxonomy is -/// the source of truth) and stamps the realm + timestamp at emit. -/// -/// PII is the caller's responsibility to minimise. Pass an attempted -/// username / masked email / IP as only where it is the -/// security signal; never put secrets, tokens, or invite codes in any field. +/// Structured input for a realm-owned security event. is +/// routing metadata only and is never persisted in the realm document. +/// is accepted transiently so the writer can HMAC +/// it with the owning realm's key; its raw value never reaches storage. +/// is disabled for non-identifying +/// cross-realm counterpart events so actor PII stays in the actor's realm. /// public sealed record SecurityAuditRecord { - /// An streamless code (security.* / - /// ops.* / audit.*). public required string EventType { get; init; } + public string? RealmSlug { get; init; } + public bool CaptureRequestContext { get; init; } = true; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public AuditActorKind? ActorKind { get; init; } + public Guid? ActorSubjectId { get; init; } + public Guid? TargetSubjectId { get; init; } + public string? UnknownIdentifier { get; init; } + public string? IpAddress { get; init; } + public string? UserAgent { get; init; } + public string? OAuthClientId { get; init; } + public string? AuthorizationId { get; init; } + public Guid? ApplicationId { get; init; } + public Guid? SessionId { get; init; } + public Guid? LoginProviderId { get; init; } + public string? AuthenticationMethod { get; init; } + public string? CorrelationId { get; init; } + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? KeyId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RemindedCount { get; init; } + public int? SelfErasedCount { get; init; } + public int? AutoPurgedCount { get; init; } + public int? InviteCodesPrunedCount { get; init; } + public int? ReusedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } +} - /// Explicit realm slug, overriding the ambient - /// TenantContext.Current. Set this from realm-iterating background - /// jobs (the signing-key janitor, DCR GC, lifecycle sweep, realm - /// provisioning) which run in the system session but emit per-realm - /// rows — exactly the case the legacy RealmLogEnricher's explicit - /// {Realm} binding handled. Leave null on the request path (the ambient - /// realm is correct there). - public string? Realm { get; init; } - - /// "Info" | "Warning" | "Error" — the legacy level mapping. - public string Level { get; init; } = "Info"; - - /// Who/what the event is about: an attempted username, a masked email, - /// an acting admin's username, or an IP for a purely anonymous actor. A display - /// string (NOT a user-id GUID) so the cross-realm read needs no per-tenant join. - /// Null when there is no meaningful actor. - public string? Actor { get; init; } - - /// Source IP where the event carries one. Personal data under CJEU - /// Breyer — retained only for the short prune window. - public string? Ip { get; init; } - - /// Coarse outcome, e.g. "rejected" | "succeeded" | "rotated". Optional. - public string? Status { get; init; } - - /// Disambiguating detail (e.g. the rejection reason, the recovery-CLI - /// operation). Already PII-minimised by the caller. - public string? Reason { get; init; } - - /// Human-readable rendering for the admin grid (carried forward from the - /// legacy free-text Message column so the existing view keeps working). - public string Message { get; init; } = ""; +/// +/// Structured deployment-wide event. The absence of subject, identifier, IP, +/// user-agent, client and session fields is an intentional compile-time privacy +/// boundary. +/// +public sealed record PlatformAuditRecord +{ + public required string EventType { get; init; } + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? Domain { get; init; } + public string? PreviousDomain { get; init; } + public string? CorrelationId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } } /// -/// Best-effort sink for the streamless security/ops audit store (Track A, Phase 3). -/// Replaces the "Auth:"-message-prefix Serilog sink: call sites emit a typed -/// instead of stringly-typed log lines. -/// -/// Contract: is non-blocking and NEVER throws — a -/// failed enqueue drops the record rather than break the auth flow. The realm is -/// captured from TenantContext.Current at call time (the background writer -/// runs tenant-less). Durability is best-effort by design: this is a short-retention -/// legitimate-interest store, not the per-subject GDPR audit (which is the -/// event-sourced AuthAuditView). +/// Best-effort, non-blocking security-event sink. Realm and platform records use +/// separate methods and payload types so storage ownership and the PII boundary +/// cannot be selected through a boolean flag. /// public interface ISecurityAuditLog { - /// Enqueue a streamless security/ops record. Non-blocking, never throws. void Record(SecurityAuditRecord record); + void RecordPlatform(PlatformAuditRecord record); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs deleted file mode 100644 index caaf2de7..00000000 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEntry.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Marten.Schema; - -namespace Modgud.Infrastructure.Audit; - -/// -/// A flat, typed, NON-event-sourced row in the streamless security/ops store -/// (logging/audit redesign Track A — the half that has no aggregate stream). One -/// document per occurrence; lives cross-realm in the system DB, attributed -/// to a realm via and scoped at read by the caller's realm + -/// (carrying PR #50's ScopeToCallerRealm forward). -/// -/// This is the successor to the personal-data-bearing-but-streamless portion -/// of the old AuthLogDocument: unknown-actor login attempts, probes, -/// rate-limit hits, and operational actions. Processed under Art. 6(1)(f) -/// (security / fraud detection); short hard retention is the proportionality -/// control (a Quartz prune), NOT per-subject erasure — there is no subject -/// stream to attach these to. See the maintainers' logging-audit-redesign design note -/// §A.5 + the Legitimate-Interest Assessment. -/// -[DocumentAlias("security_audit_entry")] -public class SecurityAuditEntry -{ - public Guid Id { get; init; } = Guid.NewGuid(); - - public DateTimeOffset Timestamp { get; init; } - - /// Realm slug the event was emitted in (from TenantContext.Current - /// at emit; background / no-tenant work is attributed to system). All rows - /// share the system DB; this column scopes the admin read. - public string? Realm { get; init; } - - /// code (derived from the event type). - public string Category { get; init; } = ""; - - /// code (a streamless security.* / ops.* / - /// audit.* code). - public string EventType { get; init; } = ""; - - /// "Info" | "Warning" | "Error". - public string Level { get; init; } = "Info"; - - /// True for control-plane-only events (cross-realm infra / platform ops). - /// Derived from the event type at emit () - /// and stored so the read can filter on a column: a tenant realm-admin sees only - /// PlatformOnly == false rows for their realm; the control-plane sees all. - public bool PlatformOnly { get; init; } - - /// Who/what the event is about — an attempted username, masked email, - /// acting admin, or IP. A display string, not a user-id GUID. May be personal - /// data; retained only for the prune window. Surfaced as the grid's "user" column. - public string? Actor { get; init; } - - /// Source IP where present. Personal data (CJEU Breyer) — retained - /// only for the prune window. - public string? Ip { get; init; } - - /// Coarse outcome ("rejected" | "succeeded" | "rotated" | …). Optional. - public string? Status { get; init; } - - /// Disambiguating detail (rejection reason, recovery-CLI operation, …). - public string? Reason { get; init; } - - /// Human-readable rendering for the admin grid (carry-forward of the - /// legacy Message column). - public string Message { get; init; } = ""; -} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs new file mode 100644 index 00000000..888b1d3d --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs @@ -0,0 +1,115 @@ +using Marten.Schema; + +namespace Modgud.Infrastructure.Audit; + +/// +/// One structured security occurrence owned by exactly one realm. The document is +/// stored in that realm's physical database; it therefore has no Realm column. +/// Personal data is allowed only in the explicit forensic fields below and is +/// hard-deleted by the realm's configurable retention job. +/// +[DocumentAlias("realm_security_audit_event")] +public sealed class RealmSecurityAuditEvent +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset Timestamp { get; init; } + public string Category { get; init; } = ""; + public string EventType { get; init; } = ""; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public AuditActorKind ActorKind { get; init; } = AuditActorKind.System; + public Guid? ActorSubjectId { get; init; } + public Guid? TargetSubjectId { get; init; } + public string? UnknownIdentifierFingerprint { get; init; } + public string? IpAddress { get; init; } + public string? UserAgent { get; init; } + public string? OAuthClientId { get; init; } + public string? AuthorizationId { get; init; } + public Guid? ApplicationId { get; init; } + public Guid? SessionId { get; init; } + public Guid? LoginProviderId { get; init; } + public string? AuthenticationMethod { get; init; } + public string? CorrelationId { get; init; } + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? KeyId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RemindedCount { get; init; } + public int? SelfErasedCount { get; init; } + public int? AutoPurgedCount { get; init; } + public int? InviteCodesPrunedCount { get; init; } + public int? ReusedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } +} + +/// +/// Deployment-wide operations event. This type deliberately has no subject, +/// identifier, IP, user-agent, client, application or session field. It lives +/// only in the non-tenanted Global Store. +/// +[DocumentAlias("platform_audit_event")] +public sealed class PlatformAuditEvent +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset Timestamp { get; init; } + public string Category { get; init; } = ""; + public string EventType { get; init; } = ""; + public AuditSeverity Severity { get; init; } = AuditSeverity.Info; + public string OutcomeCode { get; init; } = AuditOutcomes.Observed; + public string? ReasonCode { get; init; } + public string? OperationCode { get; init; } + public string? TargetRealmSlug { get; init; } + public string? Domain { get; init; } + public string? PreviousDomain { get; init; } + public string? CorrelationId { get; init; } + public int? Count { get; init; } + public int? RelatedCount { get; init; } + public int? RetentionDays { get; init; } + public DateTimeOffset? EffectiveAt { get; init; } +} + +/// +/// Per-realm secret used to turn an unresolved login/reset identifier into a +/// stable HMAC fingerprint. The raw identifier is never persisted. A separate +/// random key per physical realm prevents cross-realm correlation. +/// +[DocumentAlias("realm_audit_fingerprint_key")] +public sealed class RealmAuditFingerprintKey +{ + public const string SingletonId = "realm-security-audit-hmac-v1"; + + public string Id { get; init; } = SingletonId; + public required byte[] Key { get; init; } +} + +public enum AuditSeverity +{ + Info, + Warning, + Error, +} + +public enum AuditActorKind +{ + User, + AnonymousIdentifier, + OAuthClient, + ServiceAccount, + ControlPlane, + System, +} + +public static class AuditOutcomes +{ + public const string Observed = "observed"; + public const string Succeeded = "succeeded"; + public const string Rejected = "rejected"; + public const string Blocked = "blocked"; + public const string Failed = "failed"; + public const string Initiated = "initiated"; + public const string Completed = "completed"; + public const string Pruned = "pruned"; +} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs index 978b5fe9..ee6657a0 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs @@ -1,33 +1,28 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Threading.Channels; using Marten; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Modgud.Infrastructure.Persistence.Tenancy; +using Modgud.Infrastructure.Realms; namespace Modgud.Infrastructure.Audit; /// -/// In-process implementation of : a bounded channel -/// that drains to the system DB. -/// -/// Bounded + drop-on-full (the legacy sink was an UNBOUNDED channel — -/// a memory-growth risk under a credential-stuffing storm). When the writer can't -/// keep up the oldest behaviour we want is to shed load, never to block the auth -/// path or grow without limit. Dropped counts are exposed for the writer to log. -/// -/// The realm is captured HERE, on the calling (request) thread where -/// TenantContext.Current is set — the writer runs tenant-less in a -/// background service, exactly as RealmLogEnricher captured it for the -/// legacy sink. Category + control-plane visibility are derived from the event type -/// so the row can't disagree with the taxonomy. +/// Best-effort in-process buffer for realm-owned security events and PII-free +/// platform events. The background writer routes each envelope to its owning +/// physical store. F7 (durable delivery) remains a separate decision. /// -public sealed class SecurityAuditLog : ISecurityAuditLog +public sealed class SecurityAuditLog(IHttpContextAccessor httpContextAccessor) : ISecurityAuditLog { - // Generous bound: a real burst is absorbed; a pathological flood sheds rather - // than OOMs. SingleReader because exactly one SecurityAuditWriter drains it. - private readonly Channel _channel = - Channel.CreateBounded(new BoundedChannelOptions(50_000) + private readonly Channel _channel = + Channel.CreateBounded(new BoundedChannelOptions(50_000) { FullMode = BoundedChannelFullMode.DropWrite, SingleReader = true, @@ -35,64 +30,262 @@ public sealed class SecurityAuditLog : ISecurityAuditLog private long _dropped; - internal ChannelReader Reader => _channel.Reader; - - /// Total records dropped because the channel was full (read-and-reset - /// by the writer so it can log bursts). + internal ChannelReader Reader => _channel.Reader; internal long ReadAndResetDropped() => Interlocked.Exchange(ref _dropped, 0); public void Record(SecurityAuditRecord record) { - var entry = new SecurityAuditEntry + try { - Timestamp = DateTimeOffset.UtcNow, - // Explicit override wins (realm-iterating background jobs), else the - // ambient realm — mirrors the legacy RealmLogEnricher dual-sourcing. - Realm = record.Realm ?? TenantContext.Current, - EventType = record.EventType, - Category = AuditEvents.CategoryOf(record.EventType), - PlatformOnly = AuditEvents.IsPlatformOnly(record.EventType), - Level = record.Level, - Actor = record.Actor, - Ip = record.Ip, - Status = record.Status, - Reason = record.Reason, - Message = record.Message, - }; + var http = record.CaptureRequestContext + ? httpContextAccessor.HttpContext + : null; + var subject = record.ActorSubjectId ?? + (record.ActorKind is null or AuditActorKind.User ? TryGetSubject(http) : null); + var ip = record.IpAddress ?? http?.Connection.RemoteIpAddress?.ToString(); + var requestUserAgent = http?.Request.Headers.UserAgent.ToString(); + var userAgent = record.UserAgent ?? + (string.IsNullOrWhiteSpace(requestUserAgent) ? null : requestUserAgent); + var correlationId = record.CorrelationId ?? CurrentCorrelationId(http); + var actorKind = record.ActorKind + ?? (subject is not null + ? AuditActorKind.User + : record.UnknownIdentifier is not null + ? AuditActorKind.AnonymousIdentifier + : record.OAuthClientId is not null + ? AuditActorKind.OAuthClient + : AuditActorKind.System); - if (!_channel.Writer.TryWrite(entry)) + Enqueue(SecurityAuditEnvelope.ForRealm( + record.RealmSlug ?? TenantContext.Current, + record with + { + ActorSubjectId = subject, + IpAddress = ip, + UserAgent = userAgent, + CorrelationId = correlationId, + ActorKind = actorKind, + })); + } + catch + { + Interlocked.Increment(ref _dropped); + } + } + + public void RecordPlatform(PlatformAuditRecord record) + { + try + { + Enqueue(SecurityAuditEnvelope.ForPlatform(record with + { + CorrelationId = record.CorrelationId ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + })); + } + catch + { + Interlocked.Increment(ref _dropped); + } + } + + private void Enqueue(SecurityAuditEnvelope envelope) + { + if (!_channel.Writer.TryWrite(envelope)) Interlocked.Increment(ref _dropped); } /// - /// Synchronously drain everything currently queued to the system DB. For - /// SHORT-LIVED process paths that never start the host (so - /// never runs) — notably the recovery CLI and - /// STARTUP_COMMAND. Without this, records those paths enqueue would be lost on - /// exit, which is exactly the high-value break-glass forensic trail we must keep. - /// Safe to call when the channel is empty (no-op). NOT used on the normal web - /// path, where the background writer owns the drain. + /// Drains the buffer for short-lived recovery-CLI processes which never start + /// the hosted writer. /// - public async Task FlushAsync(IDocumentStore store, CancellationToken ct = default) + public async Task FlushAsync( + IDocumentStore realmStore, + IGlobalStore globalStore, + CancellationToken ct = default) { - var batch = new List(); + var batch = new List(); while (_channel.Reader.TryRead(out var entry)) batch.Add(entry); - if (batch.Count == 0) - return; + if (batch.Count > 0) + await SecurityAuditPersistence.PersistAsync(batch, realmStore, globalStore, ct); + } - await using var session = store.LightweightSession(TenantConstants.SystemTenantId); - session.Store(batch.ToArray()); - await session.SaveChangesAsync(ct); + private static Guid? TryGetSubject(HttpContext? http) + { + var value = http?.User.FindFirst("sub")?.Value + ?? http?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(value, out var subject) ? subject : null; + } + + private static string? CurrentCorrelationId(HttpContext? http) + => Activity.Current?.TraceId.ToString() + ?? http?.TraceIdentifier; +} + +internal sealed record SecurityAuditEnvelope +{ + public required string? RealmSlug { get; init; } + public SecurityAuditRecord? RealmRecord { get; init; } + public PlatformAuditRecord? PlatformRecord { get; init; } + + public static SecurityAuditEnvelope ForRealm(string realmSlug, SecurityAuditRecord record) + => new() { RealmSlug = realmSlug, RealmRecord = record }; + + public static SecurityAuditEnvelope ForPlatform(PlatformAuditRecord record) + => new() { RealmSlug = null, PlatformRecord = record }; +} + +internal static class SecurityAuditPersistence +{ + private static readonly ConcurrentDictionary FingerprintKeys = + new(StringComparer.OrdinalIgnoreCase); + + public static async Task PersistAsync( + IReadOnlyCollection batch, + IDocumentStore realmStore, + IGlobalStore globalStore, + CancellationToken ct) + { + foreach (var realmGroup in batch + .Where(x => x.RealmRecord is not null) + .GroupBy(x => x.RealmSlug!, StringComparer.OrdinalIgnoreCase)) + { + var key = await GetOrCreateFingerprintKeyAsync(realmStore, realmGroup.Key, ct); + var events = realmGroup + .Select(x => ToRealmEvent(x.RealmRecord!, key)) + .ToArray(); + + await using var session = realmStore.LightweightSession(realmGroup.Key); + session.Store(events); + await session.SaveChangesAsync(ct); + } + + var platformEvents = batch + .Where(x => x.PlatformRecord is not null) + .Select(x => ToPlatformEvent(x.PlatformRecord!)) + .ToArray(); + + if (platformEvents.Length > 0) + { + await using var session = globalStore.LightweightSession(); + session.Store(platformEvents); + await session.SaveChangesAsync(ct); + } + } + + private static RealmSecurityAuditEvent ToRealmEvent(SecurityAuditRecord record, byte[] key) + => new() + { + Timestamp = DateTimeOffset.UtcNow, + Category = AuditEvents.CategoryOf(record.EventType), + EventType = record.EventType, + Severity = record.Severity, + ActorKind = record.ActorKind ?? AuditActorKind.System, + ActorSubjectId = record.ActorSubjectId, + TargetSubjectId = record.TargetSubjectId, + UnknownIdentifierFingerprint = record.UnknownIdentifier is null + ? null + : Fingerprint(record.UnknownIdentifier, key), + IpAddress = record.IpAddress, + UserAgent = record.UserAgent, + OAuthClientId = record.OAuthClientId, + AuthorizationId = record.AuthorizationId, + ApplicationId = record.ApplicationId, + SessionId = record.SessionId, + LoginProviderId = record.LoginProviderId, + AuthenticationMethod = record.AuthenticationMethod, + CorrelationId = record.CorrelationId, + OutcomeCode = record.OutcomeCode, + ReasonCode = record.ReasonCode, + OperationCode = record.OperationCode, + TargetRealmSlug = record.TargetRealmSlug, + KeyId = record.KeyId, + Count = record.Count, + RelatedCount = record.RelatedCount, + RemindedCount = record.RemindedCount, + SelfErasedCount = record.SelfErasedCount, + AutoPurgedCount = record.AutoPurgedCount, + InviteCodesPrunedCount = record.InviteCodesPrunedCount, + ReusedCount = record.ReusedCount, + RetentionDays = record.RetentionDays, + EffectiveAt = record.EffectiveAt, + }; + + private static PlatformAuditEvent ToPlatformEvent(PlatformAuditRecord record) + => new() + { + Timestamp = DateTimeOffset.UtcNow, + Category = AuditEvents.CategoryOf(record.EventType), + EventType = record.EventType, + Severity = record.Severity, + OutcomeCode = record.OutcomeCode, + ReasonCode = record.ReasonCode, + OperationCode = record.OperationCode, + TargetRealmSlug = record.TargetRealmSlug, + Domain = record.Domain, + PreviousDomain = record.PreviousDomain, + CorrelationId = record.CorrelationId, + Count = record.Count, + RelatedCount = record.RelatedCount, + RetentionDays = record.RetentionDays, + EffectiveAt = record.EffectiveAt, + }; + + private static string Fingerprint(string identifier, byte[] key) + { + var normalized = identifier.Trim().Normalize(NormalizationForm.FormKC) + .ToLower(CultureInfo.InvariantCulture); + var digest = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private static async Task GetOrCreateFingerprintKeyAsync( + IDocumentStore store, + string realmSlug, + CancellationToken ct) + { + if (FingerprintKeys.TryGetValue(realmSlug, out var cached)) + return cached; + + await using (var read = store.QuerySession(realmSlug)) + { + var existing = await read.LoadAsync( + RealmAuditFingerprintKey.SingletonId, ct); + if (existing is not null) + return FingerprintKeys.GetOrAdd(realmSlug, existing.Key); + } + + var candidate = new RealmAuditFingerprintKey + { + Key = RandomNumberGenerator.GetBytes(32), + }; + + try + { + await using var write = store.LightweightSession(realmSlug); + write.Insert(candidate); + await write.SaveChangesAsync(ct); + return FingerprintKeys.GetOrAdd(realmSlug, candidate.Key); + } + catch (Exception createError) when (createError is not OperationCanceledException) + { + // Another node may have created the singleton between our read and + // insert. Use the winning key; if no row exists, preserve the real + // storage failure instead of silently changing fingerprints. + await using var retry = store.QuerySession(realmSlug); + var winner = await retry.LoadAsync( + RealmAuditFingerprintKey.SingletonId, ct); + if (winner is not null) + return FingerprintKeys.GetOrAdd(realmSlug, winner.Key); + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(createError).Throw(); + throw; + } } } -/// -/// Background service that drains into the system DB -/// in batches. Replaces the legacy AuthLogPersistenceService drain loop; the -/// retention prune that lived there is now a separate Quartz job over this store. -/// public sealed class SecurityAuditWriter( IServiceProvider services, SecurityAuditLog log, @@ -105,7 +298,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var reader = log.Reader; while (await reader.WaitToReadAsync(stoppingToken)) { - var batch = new List(MaxBatch); + var batch = new List(MaxBatch); while (batch.Count < MaxBatch && reader.TryRead(out var entry)) batch.Add(entry); @@ -115,16 +308,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { using var scope = services.CreateScope(); - // Runs out-of-band in a HostedService — no HttpContext to drive - // tenant resolution, so target the system tenant explicitly. The - // streamless store lives cross-realm in the system DB by design; - // each row already carries its own Realm captured at emit time. - await using var session = scope.ServiceProvider - .GetRequiredService() - .LightweightSession(TenantConstants.SystemTenantId); - - session.Store(batch.ToArray()); - await session.SaveChangesAsync(stoppingToken); + await SecurityAuditPersistence.PersistAsync( + batch, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs index 21cdd326..682c1fdd 100644 --- a/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs +++ b/src/dotnet/Modgud.Infrastructure/Authorization/AppRealmSeeder.cs @@ -41,7 +41,7 @@ private static readonly (string Resource, string[] Actions)[] ModgudCatalog = ("authorization-group", ["read", "write"]), ("permission-role", ["read", "write"]), - // Sessions + audit. auth-log:read = the streamless security/ops store; + // Sessions + audit. auth-log:read = this realm's security/ops store; // audit-log:read = the per-realm GDPR-audit (event-sourced) — two surfaces. ("session", ["read", "write"]), ("auth-log", ["read"]), @@ -89,6 +89,7 @@ private static readonly (string Resource, string[] Actions)[] ModgudCatalog = private static readonly (string Resource, string[] Actions)[] ControlPlaneCatalog = [ ("realm", ["read", "write"]), + ("platform-audit", ["read"]), ]; public static async Task SeedAsync( diff --git a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs index 8b6cc19b..dba31f71 100644 --- a/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs +++ b/src/dotnet/Modgud.Infrastructure/DependencyInjection.cs @@ -96,6 +96,15 @@ public static IServiceCollection AddInfrastructure( .Identity(x => x.Id) .Index(x => new { x.JobKey, x.StartedAt }); + // Deployment-wide operations are intentionally PII-free and live + // only in the non-tenanted Global Store. Realm security events are + // configured in each tenant store below. + opts.Schema.For() + .Identity(x => x.Id) + .Index(x => x.Timestamp) + .Index(x => x.EventType) + .Index(x => x.TargetRealmSlug); + // RealmSigningKey lives in the per-tenant store (configured below), // not here. Defense-in-depth: a master-DB compromise must NOT leak // every realm's private signing key — the key for realm A only sits @@ -182,10 +191,8 @@ public static IServiceCollection AddInfrastructure( opt.RegisterResource(app, "authorization-group", "read", "write"); opt.RegisterResource(app, "permission-role", "read", "write"); - // Sessions + audit. Two distinct read surfaces (logging/audit redesign): - // auth-log:read — the streamless security/ops store (failed logins on - // unknown actors, probes, rate-limits, operational - // actions). Cross-realm in the system DB. + // Sessions + audit. Two distinct realm-owned read surfaces: + // auth-log:read — structured security events in this realm DB. // audit-log:read — the per-realm GDPR-audit (event-sourced account / // login history projected from the user streams). opt.RegisterResource(app, "session", "read", "write"); @@ -228,6 +235,7 @@ public static IServiceCollection AddInfrastructure( // into their tenant DB (see AppRealmSeeder). const string controlPlaneApp = AppSlugs.ControlPlane; opt.RegisterResource(controlPlaneApp, "realm", "read", "write"); + opt.RegisterResource(controlPlaneApp, "platform-audit", "read"); }); // OAuth admin slice services — both consume the tenant-scoped IDocumentSession diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs index 7a10a0f5..079c0f3a 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs @@ -81,11 +81,13 @@ public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext c _securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.DcrClientFirstUsed, - Level = "Info", - Actor = clientId, - Status = "first_used", - Reason = $"registeredAt {registeredAt ?? "(unknown)"}", - Message = $"DCR client {clientId} used for the first time", + ActorKind = AuditActorKind.OAuthClient, + OAuthClientId = clientId, + OutcomeCode = AuditOutcomes.Observed, + OperationCode = "first-use", + EffectiveAt = DateTimeOffset.TryParse(registeredAt, out var registeredAtValue) + ? registeredAtValue + : null, }); } } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs index 7ba933c1..3b9eed4e 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs @@ -129,11 +129,14 @@ public async ValueTask HandleAsync(ValidateTokenContext context) _securityAudit.Record(new SecurityAuditRecord { EventType = AuditEvents.RefreshTokenReuseDetected, - Level = "Warning", - Actor = subject, - Status = "revoked", - Reason = $"clientId={clientId ?? "(unknown)"} authorizationId={authorizationId ?? "(unknown)"} revokedTokens={familySize}", - Message = $"Refresh token reuse detected for client '{clientId ?? "(unknown)"}' — revoking {familySize} token(s) and the parent authorization", + Severity = AuditSeverity.Warning, + ActorKind = AuditActorKind.User, + ActorSubjectId = Guid.TryParse(subject, out var subjectId) ? subjectId : null, + OAuthClientId = clientId, + AuthorizationId = authorizationId, + OutcomeCode = AuditOutcomes.Blocked, + OperationCode = "revoke-token-family", + Count = familySize, }); // Keep higher-level session models in sync with OpenIddict's imminent diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs index 55db95ab..cb0e93e6 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs @@ -242,14 +242,12 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat #pragma warning restore CA2100 await createDbCmd.ExecuteNonQueryAsync(ct); _logger.LogInformation("Created database {DbName} for realm {Slug}", tenantDbName, dto.Slug); - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RealmProvisioned, - Level = "Info", - Realm = dto.Slug, - Status = "provisioned", - Reason = $"database {tenantDbName}", - Message = $"Created database {tenantDbName} for realm {dto.Slug}", + TargetRealmSlug = dto.Slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "create-database", }); } } @@ -529,14 +527,14 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT "Irreversible — event streams, signing keys and the OpenIddict token store are gone.", slug, tenantDbName); - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RealmProvisioned, - Level = "Warning", - Realm = slug, - Status = "hard-deleted", - Reason = "operator hard-delete", - Message = $"Hard-deleted realm {slug} (tenant database {tenantDbName} dropped)", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "hard-delete", + ReasonCode = "operator-request", }); return true; @@ -574,14 +572,14 @@ public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken c "Rolled back partially-provisioned realm {Slug} after a post-create bootstrap failure. " + "The tenant database is left in place for idempotent reuse on retry.", slug); - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RealmProvisioned, - Level = "Warning", - Realm = slug, - Status = "rolled-back", - Reason = "bootstrap-invite issuance failed after realm creation", - Message = $"Rolled back partially-provisioned realm {slug} (tenant DB retained for retry)", + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "rollback-provisioning", + ReasonCode = "bootstrap-invite-failed", }); } @@ -745,14 +743,14 @@ await AppRealmSeeder.SeedAsync( _logger.LogWarning( "Control plane transferred to realm {Slug} (cleared {Count} previous holder(s))", targetSlug, otherHolders.Count); - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.ControlPlaneTransferred, - Level = "Warning", - Realm = targetSlug, - Status = "transferred", - Reason = $"to realm {targetSlug}, {otherHolders.Count} previous holder(s)", - Message = $"Control plane transferred to realm {targetSlug} (cleared {otherHolders.Count} previous holder(s))", + Severity = AuditSeverity.Warning, + TargetRealmSlug = targetSlug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "transfer", + Count = otherHolders.Count, }); return target; @@ -852,14 +850,12 @@ await seederScope.ServiceProvider _realmCache.Invalidate(); await ReconcileJobSchedulesAsync(ct); _logger.LogInformation("Adopted existing database {DbName} as realm {Slug}", tenantDbName, slug); - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordPlatform(new PlatformAuditRecord { EventType = AuditEvents.RealmAdopted, - Level = "Info", - Realm = slug, - Status = "adopted", - Reason = $"database {tenantDbName}", - Message = $"Adopted existing database {tenantDbName} as realm {slug}", + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "adopt-database", }); return realm; } diff --git a/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs b/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs index 863a2999..cab13cb6 100644 --- a/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/AuthLog/AuthLogAttributionTests.cs @@ -1,4 +1,3 @@ -using Modgud.Authentication.Api.Admin; using Modgud.Authentication.AuthLog; using Modgud.Infrastructure.Audit; using Modgud.Infrastructure.Persistence.Tenancy; @@ -8,95 +7,58 @@ namespace Modgud.Tests.Unit.AuthLog; -/// -/// Two deterministic seams of the security/audit logging: -/// (1) stamps the ambient realm on Serilog events at -/// emit time (kept after the "Auth:" sink was retired — it tags operational logs + -/// the Phase-4 OTel export); and (2) the realm + tenant-visibility scoping the admin -/// Security-log read applies ( over -/// the streamless store). -/// public class AuthLogAttributionTests { private static readonly MessageTemplateParser Parser = new(); - private static LogEvent AuthEvent(string template, params LogEventProperty[] props) => - new(DateTimeOffset.UtcNow, LogEventLevel.Warning, exception: null, Parser.Parse(template), props); - private sealed class TestPropertyFactory : ILogEventPropertyFactory { public LogEventProperty CreateProperty(string name, object? value, bool destructureObjects = false) => new(name, new ScalarValue(value)); } - // ── Enricher ──────────────────────────────────────────────────────── - [Fact] public void Enricher_StampsAmbientRealm() { - var evt = AuthEvent("Auth: signing key rotated"); + var evt = new LogEvent( + DateTimeOffset.UtcNow, + LogEventLevel.Warning, + null, + Parser.Parse("security operation"), + []); using (TenantContext.Enter("acme")) new RealmLogEnricher().Enrich(evt, new TestPropertyFactory()); - Assert.True(evt.Properties.TryGetValue("Realm", out var v)); - Assert.Equal("acme", ((ScalarValue)v).Value); - } - - [Fact] - public void Enricher_NoAmbientTenant_FallsBackToSystem() - { - var evt = AuthEvent("Auth: something happened"); - - // No TenantContext.Enter — Current falls back to the system tenant, so - // background / no-tenant events are attributed to "system" (not orphaned). - new RealmLogEnricher().Enrich(evt, new TestPropertyFactory()); - - Assert.True(evt.Properties.TryGetValue("Realm", out var v)); - Assert.Equal("system", ((ScalarValue)v).Value); - } - - // ── Read scoping (AuthLogEndpoints.ScopeToCallerRealm over the streamless store) ── - - private static IQueryable Rows() => new[] - { - new SecurityAuditEntry { Message = "a", Realm = "system", PlatformOnly = false }, - new SecurityAuditEntry { Message = "b", Realm = "acme", PlatformOnly = false }, - new SecurityAuditEntry { Message = "c", Realm = "globex", PlatformOnly = false }, - new SecurityAuditEntry { Message = "p", Realm = "acme", PlatformOnly = true }, - }.AsQueryable(); - - [Fact] - public void Scope_ControlPlane_SeesEveryRealm_IncludingPlatformOnly() - { - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "system", callerIsControlPlane: true).ToList(); - Assert.Equal(4, result.Count); // the control-plane realm sees the full cross-realm log, platform-only included - } - - [Fact] - public void Scope_TenantRealm_SeesOnlyOwnRealm_TenantVisibleOnly() - { - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "acme", callerIsControlPlane: false).ToList(); - Assert.Single(result); - Assert.Equal("b", result[0].Message); // own realm, tenant-visible — NOT the platform-only "p" row + Assert.Equal("acme", ((ScalarValue)evt.Properties["Realm"]).Value); } [Fact] - public void Scope_TenantRealm_NeverSeesPlatformOnly() + public void Realm_event_has_no_simulated_realm_or_free_text_fields() { - // A control-plane-only operational row in the caller's OWN realm must still - // be hidden from a tenant realm-admin. - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "acme", callerIsControlPlane: false).ToList(); - Assert.DoesNotContain(result, r => r.PlatformOnly); + var names = typeof(RealmSecurityAuditEvent).GetProperties() + .Select(x => x.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("Realm", names); + Assert.DoesNotContain("Actor", names); + Assert.DoesNotContain("Reason", names); + Assert.DoesNotContain("Message", names); } [Fact] - public void Scope_NonControlPlaneSystemRealm_SeesOnlyItsOwn() + public void Platform_event_type_cannot_hold_forensic_pii() { - // The leak guard: a realm named "system" that is NOT the control-plane - // holder (e.g. after a control-plane transfer) must NOT see other realms. - var result = AuthLogEndpoints.ScopeToCallerRealm(Rows(), "system", callerIsControlPlane: false).ToList(); - Assert.Single(result); - Assert.Equal("system", result[0].Realm); + var names = typeof(PlatformAuditEvent).GetProperties() + .Select(x => x.Name) + .ToHashSet(StringComparer.Ordinal); + + Assert.DoesNotContain("ActorSubjectId", names); + Assert.DoesNotContain("TargetSubjectId", names); + Assert.DoesNotContain("IpAddress", names); + Assert.DoesNotContain("UserAgent", names); + Assert.DoesNotContain("UnknownIdentifierFingerprint", names); + Assert.DoesNotContain("OAuthClientId", names); + Assert.DoesNotContain("SessionId", names); } } diff --git a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs index e6af3383..a58eb647 100644 --- a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs @@ -37,6 +37,7 @@ private static DynamicSamlSchemeManager NewManager() => private sealed class NoOpSecurityAuditLog : ISecurityAuditLog { public void Record(SecurityAuditRecord record) { } + public void RecordPlatform(PlatformAuditRecord record) { } } /// diff --git a/src/frontend-vue/src/layouts/MainLayout.vue b/src/frontend-vue/src/layouts/MainLayout.vue index bfb697ad..47165e37 100644 --- a/src/frontend-vue/src/layouts/MainLayout.vue +++ b/src/frontend-vue/src/layouts/MainLayout.vue @@ -123,6 +123,7 @@ const ADMIN_RESOURCE_PERMISSIONS = [ 'login-provider:read', 'realm:read', 'auth-log:read', + 'platform-audit:read', 'session:read', 'app:read', ] as const diff --git a/src/frontend-vue/src/models/realmSettings.ts b/src/frontend-vue/src/models/realmSettings.ts index f1250ab4..9f80fb4a 100644 --- a/src/frontend-vue/src/models/realmSettings.ts +++ b/src/frontend-vue/src/models/realmSettings.ts @@ -14,6 +14,7 @@ export interface RealmSettingsDto { Branding: BrandingSettingsDto RegistrationFields: RegistrationFieldsSettingsDto Deletion: DeletionSettingsDto + Audit: AuditSettingsDto /** Page-builder schemas keyed by slug. Read-only here — writes use * the dedicated /api/admin/customization/pages/{slug} endpoints. */ Pages: Record @@ -30,6 +31,7 @@ export interface UpdateRealmSettingsDto { Branding?: UpdateBrandingSettingsDto | null RegistrationFields?: UpdateRegistrationFieldsSettingsDto | null Deletion?: UpdateDeletionSettingsDto | null + Audit?: UpdateAuditSettingsDto | null } export interface BrowserSessionPolicyDto { @@ -89,6 +91,16 @@ export interface UpdateDeletionSettingsDto { AutoPurgeEnabled?: boolean } +export interface AuditSettingsDto { + VisibilityWindowDays: number + SecurityRetentionDays: number +} + +export interface UpdateAuditSettingsDto { + VisibilityWindowDays?: number + SecurityRetentionDays?: number +} + // Read shape for the Branding sub-section. LogoUrl/FaviconUrl are // server-resolved from the asset id (handy for the SPA to drop into // ); LogoAssetId/FaviconAssetId round-trip back into the diff --git a/src/frontend-vue/src/router/index.ts b/src/frontend-vue/src/router/index.ts index c87bd4fb..dbcd1fda 100644 --- a/src/frontend-vue/src/router/index.ts +++ b/src/frontend-vue/src/router/index.ts @@ -557,7 +557,7 @@ router.beforeEach(async (to) => { 'oauth-api:read', 'login-provider:read', 'realm:read', 'realm-settings:read', - 'auth-log:read', 'audit-log:read', 'session:read', 'observability:read', 'asset:read', + 'auth-log:read', 'audit-log:read', 'platform-audit:read', 'session:read', 'observability:read', 'asset:read', 'app:read', ] if (!ADMIN_PERMS.some((p) => authStore.hasPermission(p))) { diff --git a/src/frontend-vue/src/views/admin/AdminLogsView.vue b/src/frontend-vue/src/views/admin/AdminLogsView.vue index a10c0aa8..f3cedf00 100644 --- a/src/frontend-vue/src/views/admin/AdminLogsView.vue +++ b/src/frontend-vue/src/views/admin/AdminLogsView.vue @@ -5,8 +5,10 @@ import { CoarTabGroup, CoarTab } from '@cocoar/vue-ui' import { useI18n } from '@cocoar/vue-localization' import { useUI } from '@/composables/useUI' import { useAuthStore } from '@/stores/auth.store' +import { useAppConfigStore } from '@/stores/appconfig.store' import AuditLogView from './AuditLogView.vue' import AuthLogView from './AuthLogView.vue' +import PlatformAuditLogView from './PlatformAuditLogView.vue' // Combined "Logs" home for the two tenant-admin log surfaces (logging/audit // redesign): the GDPR audit trail (audit-log:read, /api/admin/audit) and the @@ -20,6 +22,7 @@ const ui = useUI() const route = useRoute() const router = useRouter() const authStore = useAuthStore() +const appConfig = useAppConfigStore() watch(language, () => ui.set((ctx) => { ctx.header.title = t('nav.administration', {}, 'Administration') @@ -28,10 +31,12 @@ watch(language, () => ui.set((ctx) => { ctx.content.container = false }), { immediate: true }) -type TabId = 'audit' | 'security' +type TabId = 'audit' | 'security' | 'platform' const canAudit = computed(() => authStore.hasPermission('audit-log:read')) const canSecurity = computed(() => authStore.hasPermission('auth-log:read')) +const canPlatform = computed(() => + appConfig.config.IsControlPlane && authStore.hasPermission('platform-audit:read')) // Resolve the requested tab if the caller may see it, else fall back to the // first tab they can (audit preferred). Guards against deep-linking a tab the @@ -39,7 +44,9 @@ const canSecurity = computed(() => authStore.hasPermission('auth-log:read')) function resolveTab(requested: unknown): TabId { if (requested === 'audit' && canAudit.value) return 'audit' if (requested === 'security' && canSecurity.value) return 'security' - return canAudit.value ? 'audit' : 'security' + if (requested === 'platform' && canPlatform.value) return 'platform' + if (canAudit.value) return 'audit' + return canSecurity.value ? 'security' : 'platform' } const activeTab = ref(resolveTab(route.query.tab)) @@ -64,11 +71,15 @@ watch(() => route.query.tab, (q) => { {{ t('admin.logs.tabs.security', {}, 'Security') }} + + {{ t('admin.logs.tabs.platform', {}, 'Platform') }} + +
diff --git a/src/frontend-vue/src/views/admin/AdminView.vue b/src/frontend-vue/src/views/admin/AdminView.vue index 7a08b9e0..23c9cf57 100644 --- a/src/frontend-vue/src/views/admin/AdminView.vue +++ b/src/frontend-vue/src/views/admin/AdminView.vue @@ -87,7 +87,7 @@ const sections = computed(() => [ { label: 'admin.apps.title', labelEn: 'Applications', icon: 'layout-grid', to: '/admin/apps', requirePermissions: ['app:read'] }, { label: 'admin.realms.title', labelEn: 'Realms', icon: 'globe', to: '/admin/realms', requirePermissions: ['realm:read'] }, { label: 'admin.realmSettings.title', labelEn: 'Realm Settings', icon: 'sliders-horizontal', to: '/admin/realm-settings', requirePermissions: ['realm-settings:read'] }, - { label: 'admin.logs.title', labelEn: 'Logs', icon: 'scroll-text', to: '/admin/logs', requirePermissions: ['auth-log:read', 'audit-log:read'] }, + { label: 'admin.logs.title', labelEn: 'Logs', icon: 'scroll-text', to: '/admin/logs', requirePermissions: ['auth-log:read', 'audit-log:read', 'platform-audit:read'] }, { label: 'admin.scheduledJobs.title', labelEn: 'Scheduled Jobs', icon: 'clock', to: '/admin/scheduled-jobs', requirePermissions: ['scheduled-job:read'] }, { label: 'admin.changeRequests.title', labelEn: 'Change Requests', icon: 'inbox', to: '/admin/change-requests', requirePermissions: ['user:write'] }, ], diff --git a/src/frontend-vue/src/views/admin/AuthLogView.vue b/src/frontend-vue/src/views/admin/AuthLogView.vue index e4319a30..a95e6918 100644 --- a/src/frontend-vue/src/views/admin/AuthLogView.vue +++ b/src/frontend-vue/src/views/admin/AuthLogView.vue @@ -13,21 +13,26 @@ const { t } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() const http = useHttpClient('/api/admin/auth-log') -// Streamless security/ops store (logging/audit redesign Track A — the half with no -// aggregate stream): unknown-actor login attempts, probes, rate-limits, policy -// rejections, and operational actions. Cross-realm in the system DB; a tenant -// realm-admin sees their own realm's tenant-visible rows, the control-plane realm -// sees the full cross-realm log including platform-only operational rows. +// Realm-owned structured security events. This endpoint reads the current +// realm's physical database only, including when the current realm is the +// Control Plane. interface SecurityLogEntry { + Id: string Timestamp: string - Realm: string | null Category: string EventType: string - Level: string - UserName: string | null - Ip: string | null - Status: string | null - Reason: string | null + Severity: string + ActorKind: string + Actor: string + Target: string | null + IpAddress: string | null + UserAgent: string | null + OAuthClientId: string | null + AuthenticationMethod: string | null + CorrelationId: string | null + OutcomeCode: string + ReasonCode: string | null + TargetRealmSlug: string | null Message: string } @@ -53,12 +58,6 @@ async function loadEntries() { finally { loading.value = false } } -async function clearLog() { - // Clearing is itself audited (audit.log_cleared) on the server. - await http.delete() - entries.value = [] -} - onMounted(() => { loadEntries() pollInterval = setInterval(loadEntries, 5_000) @@ -77,22 +76,23 @@ const gridBuilder = applyListGridDefaults(CoarGridBuilder.create p.data?.Level === 'Warning', - 'security-log-error': (p) => p.data?.Level === 'Error', + 'security-log-warning': (p) => p.data?.Severity === 'Warning', + 'security-log-error': (p) => p.data?.Severity === 'Error', }) .columns([ (col) => col.date('Timestamp', { includeTime: true }).header('Time', 'admin.securityLog.time').width(180), (col) => col.field('Category').header('Category', 'admin.securityLog.category').width(140), (col) => col.field('EventType').header('Event', 'admin.securityLog.event').width(220), (col) => col.field('Message').header('Detail', 'admin.securityLog.detail').flex(1), - (col) => col.field('UserName').header('Actor', 'admin.securityLog.actor').width(160), - (col) => col.field('Ip').header('IP', 'admin.securityLog.ip').width(140), - (col) => col.tag('Level', { + (col) => col.field('Actor').header('Actor', 'admin.securityLog.actor').width(170), + (col) => col.field('Target').header('Target', 'admin.securityLog.target').width(170), + (col) => col.field('TargetRealmSlug').header('Target realm', 'admin.platformLog.targetRealm').width(140), + (col) => col.field('IpAddress').header('IP', 'admin.securityLog.ip').width(140), + (col) => col.field('AuthenticationMethod').header('Method', 'admin.securityLog.method').width(110), + (col) => col.field('OAuthClientId').header('Client', 'admin.securityLog.client').width(160), + (col) => col.tag('Severity', { variantMap: { Info: 'neutral', Warning: 'warning', Error: 'error' }, }).header('Level', 'admin.securityLog.level').width(100), - // Realm attribution — constant for a tenant admin (their own realm), varies - // for the control-plane (system) realm which sees the full cross-realm log. - (col) => col.field('Realm').header('Realm', 'admin.securityLog.realm').width(120), ]) @@ -122,9 +122,6 @@ const gridBuilder = applyListGridDefaults(CoarGridBuilder.create {{ t('admin.securityLog.refresh', {}, 'Refresh') }} - - {{ t('admin.securityLog.clear', {}, 'Clear') }} - diff --git a/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue b/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue new file mode 100644 index 00000000..657d176e --- /dev/null +++ b/src/frontend-vue/src/views/admin/PlatformAuditLogView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/frontend-vue/src/views/admin/RealmSettingsView.vue b/src/frontend-vue/src/views/admin/RealmSettingsView.vue index e8197034..fb49ce8b 100644 --- a/src/frontend-vue/src/views/admin/RealmSettingsView.vue +++ b/src/frontend-vue/src/views/admin/RealmSettingsView.vue @@ -39,6 +39,8 @@ import type { UpdateAuthRateLimitsDto, DeletionSettingsDto, UpdateDeletionSettingsDto, + AuditSettingsDto, + UpdateAuditSettingsDto, RegistrationFieldsSettingsDto, UpdateRegistrationFieldsSettingsDto, FieldRequirement, @@ -59,7 +61,7 @@ watch(language, () => ui.set((ctx) => { ctx.content.hasSubNav = true }), { immediate: true }) -type TabId = 'self-registration' | 'registration-fields' | 'sessions' | 'dcr' | 'cimd' | 'native-grants' | 'auth-rate-limits' | 'deletion' | 'signing-keys' | 'pages' +type TabId = 'self-registration' | 'registration-fields' | 'sessions' | 'dcr' | 'cimd' | 'native-grants' | 'auth-rate-limits' | 'audit' | 'deletion' | 'signing-keys' | 'pages' const activeTab = ref('self-registration') const canRotateSigningKey = computed(() => authStore.hasPermission('realm-settings:write')) @@ -293,6 +295,12 @@ function emptyDeletion(): DeletionFormState { const deletionForm = ref(emptyDeletion()) const originalDeletion = ref(null) +const auditForm = ref({ + VisibilityWindowDays: 90, + SecurityRetentionDays: 7, +}) +const originalAudit = ref(null) + function deletionFromDto(d: DeletionSettingsDto): DeletionFormState { return { GraceDays: d.GraceDays, @@ -378,6 +386,8 @@ onMounted(async () => { authRateLimitsForm.value = authRateLimitsFromDto(dto.AuthRateLimits) originalDeletion.value = dto.Deletion deletionForm.value = deletionFromDto(dto.Deletion) + originalAudit.value = dto.Audit + auditForm.value = { ...dto.Audit } originalRegFields.value = dto.RegistrationFields regFieldsForm.value = regFieldsFromDto(dto.RegistrationFields) } catch (e: any) { @@ -536,6 +546,17 @@ function buildDeletionPatch(): UpdateDeletionSettingsDto | undefined { return Object.keys(patch).length === 0 ? undefined : patch } +function buildAuditPatch(): UpdateAuditSettingsDto | undefined { + const orig = originalAudit.value + if (!orig) return undefined + const patch: UpdateAuditSettingsDto = {} + if (auditForm.value.VisibilityWindowDays !== orig.VisibilityWindowDays) + patch.VisibilityWindowDays = auditForm.value.VisibilityWindowDays + if (auditForm.value.SecurityRetentionDays !== orig.SecurityRetentionDays) + patch.SecurityRetentionDays = auditForm.value.SecurityRetentionDays + return Object.keys(patch).length === 0 ? undefined : patch +} + function buildRegFieldsPatch(): UpdateRegistrationFieldsSettingsDto | undefined { const orig = originalRegFields.value if (!orig) return undefined @@ -558,8 +579,9 @@ async function save() { const clientSessionsPatch = buildClientSessionsPatch() const authRateLimitsPatch = buildAuthRateLimitsPatch() const deletionPatch = buildDeletionPatch() + const auditPatch = buildAuditPatch() const regFieldsPatch = buildRegFieldsPatch() - if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !browserSessionsPatch && !clientSessionsPatch && !authRateLimitsPatch && !deletionPatch && !regFieldsPatch) { + if (!selfRegPatch && !dcrPatch && !cimdPatch && !nativeGrantsPatch && !browserSessionsPatch && !clientSessionsPatch && !authRateLimitsPatch && !deletionPatch && !auditPatch && !regFieldsPatch) { savedFlash.value = true setTimeout(() => { savedFlash.value = false }, 1200) return @@ -576,6 +598,7 @@ async function save() { ClientSessions?: UpdateClientSessionPolicyDto AuthRateLimits?: UpdateAuthRateLimitsDto Deletion?: UpdateDeletionSettingsDto + Audit?: UpdateAuditSettingsDto RegistrationFields?: UpdateRegistrationFieldsSettingsDto } = {} if (selfRegPatch) payload.SelfRegistration = selfRegPatch @@ -586,6 +609,7 @@ async function save() { if (clientSessionsPatch) payload.ClientSessions = clientSessionsPatch if (authRateLimitsPatch) payload.AuthRateLimits = authRateLimitsPatch if (deletionPatch) payload.Deletion = deletionPatch + if (auditPatch) payload.Audit = auditPatch if (regFieldsPatch) payload.RegistrationFields = regFieldsPatch const updated = await settingsStore.patch(payload) originalSelfReg.value = updated.SelfRegistration @@ -604,6 +628,8 @@ async function save() { authRateLimitsForm.value = authRateLimitsFromDto(updated.AuthRateLimits) originalDeletion.value = updated.Deletion deletionForm.value = deletionFromDto(updated.Deletion) + originalAudit.value = updated.Audit + auditForm.value = { ...updated.Audit } originalRegFields.value = updated.RegistrationFields regFieldsForm.value = regFieldsFromDto(updated.RegistrationFields) editingSecret.value = false @@ -658,6 +684,9 @@ async function rotateSigningKey() { {{ t('admin.realmSettings.tabs.authRateLimits', {}, 'Rate Limits') }} + + {{ t('admin.realmSettings.tabs.audit', {}, 'Logs') }} + {{ t('admin.realmSettings.tabs.deletion', {}, 'Account Deletion') }} @@ -1040,6 +1069,30 @@ async function rotateSigningKey() {
+ +
+ + {{ t('admin.realmSettings.audit.hint', {}, 'Security events belong to this realm and are hard-deleted after the configured retention. The event-sourced audit history uses a separate visibility window.') }} + + + + + + + + + +
+
+

From bbb520d000af2fdf0b651f299cd9eb78d57ebb89 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 18:57:18 +0200 Subject: [PATCH 06/34] feat: classify audit event durability --- docs/admin/auth-log.md | 18 +- docs/admin/scheduled-jobs.md | 8 +- docs/contribute/audit-storage-decision.md | 7 +- docs/operate/observability.md | 6 +- .../Audit/SecurityAuditStoreTests.cs | 94 +++- .../Authorization/SigningKeyRotationTests.cs | 6 +- .../Authorization/UserInfoPerAudienceTests.cs | 22 + .../Admin/Jobs/AccountLifecycleSweepJob.cs | 2 +- .../Features/Admin/Jobs/DcrGcJob.cs | 2 +- .../Admin/Jobs/SigningKeyJanitorJob.cs | 2 +- .../Features/Admin/RealmsEndpoints.cs | 26 +- .../Auth/OAuth/DcrRegistrationEndpoints.cs | 22 +- .../Services/OAuthAdminService.cs | 18 + .../Api/Account/AccountEndpoints.cs | 4 +- .../Api/Account/BootstrapEndpoints.cs | 2 +- .../Api/Account/MagicLinkEndpoints.cs | 4 +- .../Api/Admin/AuthLogEndpoints.cs | 14 +- .../Api/Admin/RealmSettingsEndpoints.cs | 23 - .../Api/Admin/RecoveryCommands.cs | 30 +- .../ExternalAuth/ExternalLoginProcessor.cs | 42 +- .../Saml/DynamicSamlSchemeManager.cs | 6 +- .../Api/ExternalAuth/Saml/SamlLoginFlow.cs | 34 +- .../Saml/SamlMetadataRefreshService.cs | 4 +- .../Saml/SamlSpCertificateService.cs | 10 +- .../RealmSettings/RealmSettingsService.cs | 24 +- .../Setup/PendingAdminInviteService.cs | 21 +- .../Audit/AuditDurability.cs | 82 ++++ .../Audit/AuditEvents.cs | 42 +- .../Audit/ISecurityAuditLog.cs | 45 +- .../Audit/SecurityAuditEvents.cs | 2 + .../Audit/SecurityAuditLog.cs | 432 +++++++++++++++--- .../OpenIddict/DcrLastUsedTrackerHandler.cs | 2 +- .../RefreshTokenReuseAuditHandler.cs | 4 +- .../Realms/RealmKeyStore.cs | 17 +- .../Realms/RealmProvisioningService.cs | 110 +++-- .../Audit/AuditDurabilityTests.cs | 60 +++ .../DynamicSamlSchemeManagerTests.cs | 17 +- .../src/views/admin/AuthLogView.vue | 2 + 38 files changed, 994 insertions(+), 272 deletions(-) create mode 100644 src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs create mode 100644 src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs diff --git a/docs/admin/auth-log.md b/docs/admin/auth-log.md index 433d06bd..c16cf909 100644 --- a/docs/admin/auth-log.md +++ b/docs/admin/auth-log.md @@ -71,6 +71,18 @@ deployment-wide from the Control Plane. It has no clear action. | `GET` | `/api/admin/auth-log?category=...&eventType=...&limit=...` | `auth-log:read` | | `GET` | `/api/admin/platform-audit?category=...&eventType=...&limit=...` | `control-plane:platform-audit:read` + Control-Plane realm | -Both streamless feeds are currently best-effort. The event-sourced Audit tab -has different durability semantics. Do not describe either streamless feed as -a cryptographic or tamper-proof audit chain. +## Delivery guarantees + +Every streamless event type has one fixed durability class. A call site cannot +choose a weaker path; attempting to record an event through the wrong class +fails immediately. + +| Class | Used for | Guarantee | +|---|---|---| +| **Required** | Privileged or irreversible changes, trust-material changes and refresh-token reuse teardown | Stored in the same Marten transaction as the realm/global state change where both share a database. Cross-database DDL operations write a durable `initiated` record before the external step and a `completed` record with the Global Store mutation. Other callers wait for persistence before reporting success. | +| **Incident** | Individual takeover, tamper, signature and protocol-correlation failures | The rejecting request waits for the individual event to persist. A storage failure is not silently downgraded. | +| **Abuse** | Attacker-amplifiable login, magic-link, policy, DCR and rate-limit signals | Raw occurrences enter a bounded in-memory buffer and may be shed under pressure. Accepted bursts are coalesced by structured identity into rows carrying `Count`, `FirstObservedAt` and `LastObservedAt`; persistence retries while the process remains alive. This is deliberately bounded, not a lossless request journal. | +| **Telemetry** | Reconstructable cleanup and refresh summaries | Explicitly best-effort. A failed write is logged and does not make the operation fail. | + +The event-sourced Audit tab has its own transactional semantics. None of these +surfaces is a cryptographic or tamper-proof audit chain. diff --git a/docs/admin/scheduled-jobs.md b/docs/admin/scheduled-jobs.md index 9b9b8461..74813fa8 100644 --- a/docs/admin/scheduled-jobs.md +++ b/docs/admin/scheduled-jobs.md @@ -22,12 +22,12 @@ Every realm job has its own Quartz job + trigger. Run history (`JobRunHistoryEnt ## Registered jobs -Eight job definitions ship with Modgud today: +Nine job definitions ship with Modgud today: -- Six are **realm jobs**. Each active realm gets an independent Quartz job and trigger, so one customer can run at 18:00, another at 21:00, and another can disable its cron and run manually. -- Two are **system jobs**: `system-job-run-history-retention` and `security-audit-prune`. Each exists exactly once because it operates on a deployment-wide store, and is visible/configurable only in the realm that currently holds the Control-Plane role. +- Seven are **realm jobs**. Each active realm gets an independent Quartz job and trigger, so one customer can run at 18:00, another at 21:00, and another can disable its cron and run manually. +- Two are **system jobs**: `system-job-run-history-retention` and `platform-audit-prune`. Each exists exactly once because it operates on a deployment-wide store, and is visible/configurable only in the realm that currently holds the Control-Plane role. -The Control-Plane realm is still a realm, so it also owns its own copies of all five realm jobs. +The Control-Plane realm is still a realm, so it also owns its own copies of all seven realm jobs. System-job configuration and history live in the non-tenanted global store, not in the Control-Plane realm's database. Transferring the Control-Plane role diff --git a/docs/contribute/audit-storage-decision.md b/docs/contribute/audit-storage-decision.md index 7ddf75ea..68d097f8 100644 --- a/docs/contribute/audit-storage-decision.md +++ b/docs/contribute/audit-storage-decision.md @@ -21,5 +21,8 @@ F2 (tenant isolation) and F4 (erasure) are resolved as one storage decision: retention expires. A realm hard-delete removes them with the database. 7. Realm Security retention defaults to 7 days (1–365); Platform retention defaults to 365 days. Arbitrary clear/delete endpoints do not exist. -8. Delivery durability remains a separate F7 decision; these streamless feeds - are still best-effort. +8. F7 assigns every streamless event type one enforced delivery class: + Required (transactional or synchronously durable), Incident + (synchronously durable), Abuse (bounded raw input plus retrying count + aggregates), or Telemetry (explicitly best-effort). The complete operational + contract is documented in [Security and platform logs](../admin/auth-log.md#delivery-guarantees). diff --git a/docs/operate/observability.md b/docs/operate/observability.md index 7366b6df..2c40723f 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -151,8 +151,10 @@ Two limits worth knowing, both because the targeted values have no machine-recog The export is **best-effort and lossy by design**. It must never be load-bearing. The event-sourced tenant audit (`/admin/audit`) is a separate pipeline. The -structured Security and Platform feeds are also currently best-effort, but are -stored independently of observability export. +structured Security and Platform feeds are also independent of observability +export and use their own per-event durability classes: transactional/synchronous +for required changes and incidents, bounded aggregation for abuse signals, and +best-effort only for reconstructable operations telemetry. | Situation | What happens | What to do | | --- | --- | --- | diff --git a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs index dda6a908..4e99df09 100644 --- a/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Audit/SecurityAuditStoreTests.cs @@ -80,7 +80,7 @@ public async Task Unknown_identifier_is_persisted_only_as_realm_hmac() using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) { - audit.Record(new SecurityAuditRecord + audit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, ActorKind = AuditActorKind.AnonymousIdentifier, @@ -91,7 +91,7 @@ public async Task Unknown_identifier_is_persisted_only_as_realm_hmac() } using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter(otherRealm)) { - audit.Record(new SecurityAuditRecord + audit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, ActorKind = AuditActorKind.AnonymousIdentifier, @@ -132,6 +132,96 @@ public async Task Unknown_identifier_is_persisted_only_as_realm_hmac() acmeRecorded!.UnknownIdentifierFingerprint); } + [Fact] + public async Task Required_event_uses_the_callers_business_transaction() + { + var ct = TestContext.Current.CancellationToken; + var committedMarker = $"atomic-audit-{Guid.NewGuid():N}"; + var abandonedMarker = $"abandoned-audit-{Guid.NewGuid():N}"; + var audit = Factory.Services.GetRequiredService(); + + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) + { + await using (var abandoned = GetTenantedDocumentSession("system")) + { + audit.StoreRequired(abandoned, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + OperationCode = abandonedMarker, + RetentionDays = 14, + OutcomeCode = AuditOutcomes.Succeeded, + }); + // Deliberately no SaveChangesAsync: the business transaction + // is abandoned, therefore its audit row must be abandoned too. + } + + await using (var committed = GetTenantedDocumentSession("system")) + { + audit.StoreRequired(committed, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + OperationCode = committedMarker, + RetentionDays = 30, + OutcomeCode = AuditOutcomes.Succeeded, + }); + await committed.SaveChangesAsync(ct); + } + } + + await using var read = GetTenantedDocumentSession("system"); + Assert.Null(await read.Query() + .FirstOrDefaultAsync(x => x.OperationCode == abandonedMarker, ct)); + var committedRow = await read.Query() + .FirstOrDefaultAsync(x => x.OperationCode == committedMarker, ct); + Assert.NotNull(committedRow); + Assert.Equal(30, committedRow!.RetentionDays); + } + + [Fact] + public async Task Abuse_burst_is_persisted_as_bounded_count_aggregate() + { + var ct = TestContext.Current.CancellationToken; + var marker = $"abuse-aggregate-{Guid.NewGuid():N}"; + var audit = Factory.Services.GetRequiredService(); + + using (Modgud.Infrastructure.Persistence.Tenancy.TenantContext.Enter("system")) + { + for (var i = 0; i < 3; i++) + { + audit.RecordAbuse(new SecurityAuditRecord + { + EventType = AuditEvents.LoginFailedUnknownUser, + ActorKind = AuditActorKind.AnonymousIdentifier, + UnknownIdentifier = "aggregate@example.test", + IpAddress = "203.0.113.80", + OutcomeCode = AuditOutcomes.Rejected, + ReasonCode = marker, + }); + } + } + + IReadOnlyList rows = []; + for (var attempt = 0; attempt < 25; attempt++) + { + await using var read = GetTenantedDocumentSession("system"); + rows = await read.Query() + .Where(x => x.ReasonCode == marker) + .ToListAsync(ct); + if (rows.Sum(x => x.Count ?? 1) >= 3) + break; + await Task.Delay(200, ct); + } + + Assert.Equal(3, rows.Sum(x => x.Count ?? 1)); + Assert.Contains(rows, x => x.Count == 3); + Assert.All(rows, x => + { + Assert.NotNull(x.FirstObservedAt); + Assert.NotNull(x.LastObservedAt); + Assert.True(x.LastObservedAt >= x.FirstObservedAt); + }); + } + [Fact] public async Task Security_log_has_no_clear_endpoint() { diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs index 52e6ceec..c492b63e 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/SigningKeyRotationTests.cs @@ -6,6 +6,7 @@ using Marten; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using Modgud.Infrastructure.Audit; namespace Modgud.Api.Tests.Authorization; @@ -38,7 +39,10 @@ private sealed class TestClock(DateTimeOffset start) : TimeProvider } private RealmKeyStore NewStore(TimeProvider clock) => - new(Factory.Services.GetRequiredService(), clock); + new( + Factory.Services.GetRequiredService(), + clock, + Factory.Services.GetRequiredService()); private static List Kids(IReadOnlyList keys) => keys.Select(k => k.KeyId).ToList(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs index 0a499634..4bd1c535 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/UserInfoPerAudienceTests.cs @@ -8,6 +8,7 @@ using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; using Modgud.Authentication.Domain; +using Modgud.Authentication.Sessions; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -549,6 +550,27 @@ private async Task CreateFederatedCookieClientAsync(string userName, foreach (var gid in sessionGroupIds) identity.AddClaim(new Claim(FederationClaimTypes.SessionGroup, gid.ToString())); + // Browser sessions are authoritative since F3. A hand-forged cookie + // therefore needs the same signed session-id claim and backing row as + // a real SignInManager login; otherwise OnValidatePrincipal correctly + // rejects it before the authorize endpoint. + using (TenantContext.Enter(TenantConstants.SystemTenantId)) + { + var createdSession = await scope.ServiceProvider + .GetRequiredService() + .CreateSessionAsync( + user.Id, + ipAddress: null, + userAgent: "UserInfoPerAudienceTests", + TestContext.Current.CancellationToken); + Assert.False( + createdSession.IsError, + createdSession.IsError ? createdSession.FirstError.Description : null); + identity.AddClaim(new Claim( + SessionClaimTypes.BrowserSessionId, + createdSession.Value.Id.ToString())); + } + var cookieOptions = scope.ServiceProvider .GetRequiredService>() .Get(IdentityConstants.ApplicationScheme); diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs index 88b26c94..5a1f062f 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/AccountLifecycleSweepJob.cs @@ -51,7 +51,7 @@ public async Task Execute(IJobExecutionContext context) if (reminded + erased + purged + inviteCodesPruned > 0) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.AccountLifecycleSwept, RealmSlug = realmSlug, diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs index 5b1ac2d3..bfea965d 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/DcrGcJob.cs @@ -90,7 +90,7 @@ private async Task SweepRealmAsync( tenantSession.Events.Append(state.Id, aggregate.Delete()); swept++; - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.DcrClientGarbageCollected, RealmSlug = tenantId, diff --git a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs index 0c4e875d..e2f8c861 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Jobs/SigningKeyJanitorJob.cs @@ -44,7 +44,7 @@ public async Task Execute(IJobExecutionContext context) if (purged > 0) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.SigningKeyPurged, RealmSlug = realmSlug, diff --git a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs index 9eee6697..2ec35739 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/RealmsEndpoints.cs @@ -110,7 +110,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, realm.Slug); await svc.RollbackProvisionedRealmAsync(realm.Slug, ct); - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, realm.Slug, @@ -127,7 +127,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, + "See the server logs / the realm error feed for the underlying cause."); } - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, realm.Slug, @@ -212,7 +212,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, ct); } - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, slug, @@ -240,7 +240,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, var result = await svc.UpdateRealmAsync(slug, dto, ct); if (!result.IsError) { - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, slug, @@ -267,7 +267,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, : await svc.DeleteRealmAsync(slug, ct); if (!result.IsError) { - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, slug, @@ -295,7 +295,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, var result = await applier.ImportNewRealmAsync(manifest, ct); if (result.IsError) return ManifestError(result.Errors); ModgudMeters.RecordRealmProvisioned(); - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, result.Value.Slug, @@ -329,7 +329,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, var result = await applier.UpdateRealmAsync(manifest, prune, ct); if (!result.IsError) { - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, slug, @@ -406,7 +406,7 @@ public static WebApplication MapRealmsEndpoints(this WebApplication application, var result = await svc.TransferControlPlaneAsync(slug, ct); if (!result.IsError) { - RecordControlPlaneRealmOperation( + await RecordControlPlaneRealmOperationAsync( securityAudit, http, slug, @@ -451,7 +451,7 @@ private static async Task TargetHasUsableAdminAsync( } } - private static void RecordControlPlaneRealmOperation( + private static async Task RecordControlPlaneRealmOperationAsync( ISecurityAuditLog securityAudit, HttpContext http, string targetRealmSlug, @@ -463,7 +463,7 @@ private static void RecordControlPlaneRealmOperation( var actorRealmSlug = TenantContext.Current; var correlationId = Activity.Current?.TraceId.ToString() ?? http.TraceIdentifier; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.ControlPlaneRealmOperation, RealmSlug = actorRealmSlug, @@ -472,13 +472,13 @@ private static void RecordControlPlaneRealmOperation( ReasonCode = reasonCode, OperationCode = operationCode, CorrelationId = correlationId, - }); + }, http.RequestAborted); if (!writeTargetRealm || string.Equals(actorRealmSlug, targetRealmSlug, StringComparison.OrdinalIgnoreCase)) return; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.ControlPlaneRealmOperation, RealmSlug = targetRealmSlug, @@ -488,7 +488,7 @@ private static void RecordControlPlaneRealmOperation( ReasonCode = reasonCode, OperationCode = operationCode, CorrelationId = correlationId, - }); + }, http.RequestAborted); } // Renders a RealmManifestApplier ErrorOr error with the code in the body — the manifest diff --git a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs index e36f49a1..dbe2cfde 100644 --- a/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Auth/OAuth/DcrRegistrationEndpoints.cs @@ -129,6 +129,15 @@ private static async Task RegisterAsync( sourceIp, settings.AccessTokenLifetime, settings.RefreshTokenLifetime), + transaction => securityAudit.StoreRequired(transaction, new SecurityAuditRecord + { + EventType = AuditEvents.DcrClientRegistered, + ActorKind = AuditActorKind.OAuthClient, + OAuthClientId = normalized.ClientId, + IpAddress = sourceIp, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "register", + }), ct); if (createResult.IsError) { @@ -159,15 +168,6 @@ private static async Task RegisterAsync( // store, so without this push the grid stays stale until a manual reload. dispatcher.DispatchCreatedEvent("OAuthClient", created, session.TenantId); - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.DcrClientRegistered, - ActorKind = AuditActorKind.OAuthClient, - OAuthClientId = created.ClientId, - IpAddress = sourceIp, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "register", - }); ModgudMeters.RecordDcrRegistration(ModgudMeters.DcrOutcome.Success); // ───────── Response ───────── @@ -221,7 +221,7 @@ private static string ResolveRealmSlug(HttpContext ctx) private static void LogRejected(ISecurityAuditLog securityAudit, string ip, string? clientName, DcrRejectionReason reason) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.DcrRegistrationRejected, Severity = AuditSeverity.Warning, @@ -234,7 +234,7 @@ private static void LogRejected(ISecurityAuditLog securityAudit, string ip, stri private static void LogRateLimit(ISecurityAuditLog securityAudit, string ip, DcrRejectionReason reason) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.RateLimitTriggered, Severity = AuditSeverity.Warning, diff --git a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs index 5cbb6b0a..b46ffebb 100644 --- a/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs +++ b/src/dotnet/Modgud.Application/Services/OAuthAdminService.cs @@ -80,6 +80,23 @@ public Task> CreateClientAsync( /// public async Task> CreateClientAsync( CreateOAuthClientDto dto, DcrMetadataInput? dcrMetadata, CancellationToken ct = default) + => await CreateClientAsync( + dto, + dcrMetadata, + enlistInTransaction: null, + ct); + + ///

+ /// DCR-capable create path with an optional same-session enlistment hook. + /// The API layer uses this to add its infrastructure-owned required audit + /// document before this service commits, without introducing an + /// Application-to-Infrastructure dependency. + /// + public async Task> CreateClientAsync( + CreateOAuthClientDto dto, + DcrMetadataInput? dcrMetadata, + Action? enlistInTransaction, + CancellationToken ct = default) { if (dto.ClientType is not (OAuthClientTypes.Public or OAuthClientTypes.Confidential)) return OAuthErrors.InvalidClientType(dto.ClientType); @@ -257,6 +274,7 @@ public async Task> CreateClientAsync( _session.Store(sec); } + enlistInTransaction?.Invoke(_session); await _session.SaveChangesAsync(ct); // Reload projected state so the response reflects the persisted view. diff --git a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs index cb3f040d..519794e1 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/AccountEndpoints.cs @@ -107,7 +107,7 @@ public static WebApplication MapAccountEndpoints(this WebApplication application // by latency. Burn an equivalent hash verify before the 401. PasswordTimingSafety.EqualizeFailure(userManager.PasswordHasher, request.Password); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, Severity = AuditSeverity.Warning, @@ -138,7 +138,7 @@ public static WebApplication MapAccountEndpoints(this WebApplication application if (realmSettings?.SelfRegistration?.RequireEmailVerification == true && !user.EmailConfirmed) { await signInManager.SignOutAsync(); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailed, Severity = AuditSeverity.Warning, diff --git a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs index 5de219c2..54975754 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/BootstrapEndpoints.cs @@ -50,7 +50,7 @@ public static WebApplication MapBootstrapEndpoints(this WebApplication app, stri var result = await inviteService.ConsumeAsync(request.Token, request.Password); if (result.IsError) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.BootstrapInviteRejected, Severity = AuditSeverity.Warning, diff --git a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs index f936b1a4..76203829 100644 --- a/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Account/MagicLinkEndpoints.cs @@ -188,7 +188,7 @@ await emailService.SendTemplatedEmailAsync( if (challenge is null || challenge.IsExpired || challenge.IsConsumed) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.MagicLinkInvalid, Severity = AuditSeverity.Warning, @@ -213,7 +213,7 @@ await emailService.SendTemplatedEmailAsync( var user = await userManager.FindByIdAsync(request.UserId.ToString()); if (user is null || user.IsDeleted || !user.IsActive) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { EventType = AuditEvents.LoginFailedUnknownUser, Severity = AuditSeverity.Warning, diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs index e0715f7d..9f8009e8 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/AuthLogEndpoints.cs @@ -126,6 +126,8 @@ public sealed record RealmSecurityLogDto( int? RelatedCount, int? RetentionDays, DateTimeOffset? EffectiveAt, + DateTimeOffset? FirstObservedAt, + DateTimeOffset? LastObservedAt, string Message) { internal static RealmSecurityLogDto From( @@ -157,6 +159,8 @@ internal static RealmSecurityLogDto From( row.RelatedCount, row.RetentionDays, row.EffectiveAt, + row.FirstObservedAt, + row.LastObservedAt, AuditEventRenderer.Render(row)); private static string RenderActor( @@ -237,6 +241,8 @@ public static string Render(RealmSecurityAuditEvent row) Add(details, "invite-codes-pruned", row.InviteCodesPrunedCount); Add(details, "reused", row.ReusedCount); Add(details, "effective-at", row.EffectiveAt); + Add(details, "first-observed-at", row.FirstObservedAt); + Add(details, "last-observed-at", row.LastObservedAt); return Compose(row.EventType, row.OutcomeCode, details); } @@ -262,9 +268,12 @@ private static string Compose( { var occurrence = eventType switch { + AuditEvents.LoginFailed => "Login", AuditEvents.LoginFailedUnknownUser => "Login for an unknown identifier", AuditEvents.MagicLinkInvalid => "Invalid or expired magic link", - AuditEvents.ExternalLoginRejected => "External login", + AuditEvents.ExternalLoginProtocolRejected => "External login protocol", + AuditEvents.ExternalLoginPolicyRejected => "External login policy", + AuditEvents.ExternalLoginConfigurationError => "External login configuration", AuditEvents.SamlSignatureRejected => "SAML signature validation", AuditEvents.IdentityHijackBlocked => "External identity takeover attempt", AuditEvents.JitEmailConflict => "JIT email conflict", @@ -277,7 +286,8 @@ private static string Compose( AuditEvents.SigningKeyRotated => "Signing key rotation", AuditEvents.SigningKeyPurged => "Signing-key cleanup", AuditEvents.SamlCertRotated => "SAML certificate rotation", - AuditEvents.SamlMetadataRefreshed => "SAML metadata refresh", + AuditEvents.SamlMetadataRefreshCompleted => "SAML metadata refresh", + AuditEvents.SamlSigningCertificatesChanged => "SAML signing certificates", AuditEvents.RecoveryCliInvoked => "Recovery CLI operation", AuditEvents.RealmProvisioned => "Realm provisioning", AuditEvents.RealmAdopted => "Realm adoption", diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs index bd7ad3cf..f6aead4f 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs @@ -44,22 +44,9 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, group.MapPatch("", async ( UpdateRealmSettingsDto dto, IRealmSettingsService svc, - ISecurityAuditLog securityAudit, CancellationToken ct) => { var result = await svc.PatchAsync(dto, ct); - if (!result.IsError && dto.Audit?.SecurityRetentionDays is { } retentionDays) - { - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.SecurityRetentionChanged, - Severity = AuditSeverity.Warning, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "change-retention", - RetentionDays = retentionDays, - }); - } - return result.Match( ok => Results.Ok(ok), errors => Results.Problem( @@ -79,22 +66,12 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, // realm-settings:write permission as the rest of this surface. group.MapPost("rotate-signing-key", async ( IRealmKeyStore keyStore, - ISecurityAuditLog securityAudit, CancellationToken ct) => { var slug = TenantContext.Current; var creds = await keyStore.RotateAsync(slug, ct); var kid = creds.Key.KeyId; - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.SigningKeyRotated, - Severity = AuditSeverity.Warning, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "rotate", - KeyId = kid, - }); - return Results.Ok(new RotateSigningKeyResponseDto(kid)); }) .WithName("RealmSettings_RotateSigningKey") diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs index 2492a253..e16a2740 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs @@ -124,7 +124,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -199,7 +199,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -270,7 +270,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var appUrl = RealmPublicUrl.RealmPublicBaseUrl(realm, ctx.Env); var url = $"{appUrl}/magic-login?userId={user.Id}&token={Uri.EscapeDataString(token)}"; - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -311,7 +311,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine("Rebuilding Marten projections..."); var securityAudit = ctx.Services.GetRequiredService(); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -334,7 +334,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await daemon.RebuildProjectionAsync(timeout, CancellationToken.None); ctx.WriteLine(" OK PermissionRoleProjection (mt_doc_permissionrole)"); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -385,7 +385,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var securityAudit = ctx.Services.GetRequiredService(); if (result.IsError) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -400,7 +400,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) } var admin = result.Value; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -438,7 +438,7 @@ private static async Task IssueInviteAsync( issuedBy: null, // CLI invocation — no authenticated CP-admin realm); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -537,7 +537,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) await session.SaveChangesAsync(); - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -638,7 +638,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Added '{domain}' to realm '{slug}'. Now: [{string.Join(", ", realm.Domains)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -694,7 +694,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine($"✓ Removed '{domain}' from realm '{slug}'. Now: [{string.Join(", ", remaining)}]"); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -755,7 +755,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) ctx.WriteLine(" affected users must re-register their passkeys (other login"); ctx.WriteLine(" methods are unaffected)."); ctx.PrintRestartHint(); - ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -810,7 +810,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -856,7 +856,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) if (result.IsError) return ctx.Fail($"{result.FirstError.Code}: {result.FirstError.Description}"); - ctx.Services.GetRequiredService().RecordPlatform(new PlatformAuditRecord + await ctx.Services.GetRequiredService().RecordPlatformRequiredAsync(new PlatformAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, @@ -886,7 +886,7 @@ public async Task ExecuteAsync(RecoveryCliContext ctx) var creds = await keyStore.RotateAsync(ctx.RealmSlug); var kid = creds.Key.KeyId; - ctx.Services.GetRequiredService().Record(new SecurityAuditRecord + await ctx.Services.GetRequiredService().RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RecoveryCliInvoked, Severity = AuditSeverity.Warning, diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs index 6336bb5d..6702e13d 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalLoginProcessor.cs @@ -61,9 +61,9 @@ public async Task ProcessAsync( if (config.Type != LoginProviderType.Oidc && config.Type != LoginProviderType.Saml) { var err = LoginProviderErrors.TypeNotSupported(config.Type); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginConfigurationError, Severity = AuditSeverity.Warning, LoginProviderId = loginProviderId, AuthenticationMethod = "external", @@ -82,15 +82,15 @@ public async Task ProcessAsync( if (string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject)) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginProtocolRejected, Severity = AuditSeverity.Warning, LoginProviderId = loginProviderId, AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), OutcomeCode = AuditOutcomes.Rejected, ReasonCode = "missing-issuer-or-subject", - }); + }, ct); return ExternalLoginResult.Failed("Idp.InvalidToken", "The identity provider did not return a subject."); } @@ -147,7 +147,7 @@ public async Task ProcessAsync( // live one is not.) if (authenticatedUserId is { } authId && authId != link.UserId) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, Severity = AuditSeverity.Warning, @@ -157,7 +157,7 @@ public async Task ProcessAsync( AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), OutcomeCode = AuditOutcomes.Blocked, ReasonCode = "subject-linked-to-different-user", - }); + }, ct); return ExternalLoginResult.Failed("Idp.LinkedToOtherUser", "This identity is already linked to another Modgud account."); } @@ -210,9 +210,9 @@ public async Task ProcessAsync( var email = scriptResult.Email.Presence == FieldPresence.Value ? scriptResult.Email.Value : null; if (!IsEmailAllowed(config, email)) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginPolicyRejected, Severity = AuditSeverity.Warning, ActorKind = AuditActorKind.AnonymousIdentifier, UnknownIdentifier = email, @@ -245,7 +245,7 @@ public async Task ProcessAsync( // specifically-configured IdP, which is the trust anchor.) if (!IsEmailLinkTrustworthy(config, rawClaims, email)) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.IdentityHijackBlocked, Severity = AuditSeverity.Warning, @@ -254,7 +254,7 @@ public async Task ProcessAsync( AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), OutcomeCode = AuditOutcomes.Blocked, ReasonCode = "email-not-verified-by-provider", - }); + }, ct); return ExternalLoginResult.Failed("Idp.EmailNotVerified", "The identity provider did not verify this email address, so it cannot be auto-linked to an existing account."); } @@ -282,9 +282,9 @@ public async Task ProcessAsync( // 4. JIT user creation if (!config.AutoCreateUsers) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginPolicyRejected, Severity = AuditSeverity.Warning, ActorKind = AuditActorKind.AnonymousIdentifier, UnknownIdentifier = email, @@ -309,7 +309,7 @@ public async Task ProcessAsync( .FirstOrDefaultAsync(ct); if (emailOwnerId != Guid.Empty) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, Severity = AuditSeverity.Warning, @@ -318,7 +318,7 @@ public async Task ProcessAsync( AuthenticationMethod = config.Type.ToString().ToLowerInvariant(), OutcomeCode = AuditOutcomes.Rejected, ReasonCode = "email-owned-by-existing-user", - }); + }, ct); return ExternalLoginResult.Failed("Idp.EmailConflict", "A Modgud account with this email already exists. Please contact your administrator."); } @@ -368,7 +368,7 @@ public async Task ProcessAsync( .FirstOrDefaultAsync(ct); if (clashingUserId != Guid.Empty) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.JitEmailConflict, Severity = AuditSeverity.Warning, @@ -376,7 +376,7 @@ public async Task ProcessAsync( TargetSubjectId = clashingUserId, OutcomeCode = AuditOutcomes.Rejected, ReasonCode = "user-update-email-conflict", - }); + }, ct); return new ApplyUpdatesError( "Idp.EmailConflict", "The identity provider reports an email that is already used by another Modgud account."); @@ -454,9 +454,9 @@ private async Task Success( // IsActive=true, so the JIT path passes this gate unaffected. if (user.IsDeleted || !user.IsActive) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordAbuse(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginPolicyRejected, Severity = AuditSeverity.Warning, TargetSubjectId = user.Id, LoginProviderId = loginProviderId, @@ -522,7 +522,7 @@ private async Task Success( // lives in the Authorization layer, which cannot reach the audit store, // so it surfaces the count and we record the security event here. if (derived.DroppedRealmAdminCount > 0) - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.PrivilegeEscalationBlocked, Severity = AuditSeverity.Warning, @@ -532,7 +532,7 @@ private async Task Success( OutcomeCode = AuditOutcomes.Blocked, ReasonCode = "realm-admin-group-derived-externally", Count = derived.DroppedRealmAdminCount, - }); + }, ct); } return new ExternalLoginResult( diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs index 1175f2eb..d56d421f 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/DynamicSamlSchemeManager.cs @@ -228,9 +228,9 @@ public async Task RefreshMetadataAsync(Guid loginProviderId, CancellationT { var oldCount = existing.IdpMetadata?.SigningCertificatesBase64.Count ?? 0; var newCount = fresh.SigningCertificatesBase64.Count; - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordRequiredAsync(new SecurityAuditRecord { - EventType = AuditEvents.SamlMetadataRefreshed, + EventType = AuditEvents.SamlSigningCertificatesChanged, RealmSlug = existing.RealmSlug, ActorKind = AuditActorKind.System, LoginProviderId = loginProviderId, @@ -238,7 +238,7 @@ public async Task RefreshMetadataAsync(Guid loginProviderId, CancellationT OperationCode = "signing-certificates-changed", Count = newCount, RelatedCount = oldCount, - }); + }, ct); } return true; diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs index 578811dc..2b5bf184 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlLoginFlow.cs @@ -57,9 +57,9 @@ public async Task StartLoginAsync( { if (provider.IdpMetadata is null) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginConfigurationError, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, AuthenticationMethod = "saml", @@ -72,9 +72,9 @@ public async Task StartLoginAsync( if (string.IsNullOrEmpty(provider.IdpMetadata.SsoRedirectUrl) && string.IsNullOrEmpty(provider.IdpMetadata.SsoPostUrl)) { - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginConfigurationError, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, AuthenticationMethod = "saml", @@ -150,9 +150,9 @@ public async Task HandleAcsAsync( logger.LogWarning(ex, "SAML context build failed for provider {Id}", provider.LoginProviderId); - securityAudit.Record(new SecurityAuditRecord + securityAudit.RecordTelemetry(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginConfigurationError, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, IpAddress = ip, @@ -179,32 +179,32 @@ public async Task HandleAcsAsync( logger.LogWarning(ex, "SAML response read/validate failed for provider {Id}", provider.LoginProviderId); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginProtocolRejected, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, IpAddress = ip, AuthenticationMethod = "saml", OutcomeCode = AuditOutcomes.Rejected, ReasonCode = "response-validation-failed", - }); + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect("/login?error=saml-invalid"); } if (saml2Response.Status != Saml2StatusCodes.Success) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginProtocolRejected, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, IpAddress = ip, AuthenticationMethod = "saml", OutcomeCode = AuditOutcomes.Rejected, ReasonCode = $"saml-status:{saml2Response.Status}", - }); + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(saml2Response.Status.ToString() ?? "status")}"); } @@ -218,7 +218,7 @@ public async Task HandleAcsAsync( var sigError = CheckRequiredSignatures(saml2Response.XmlDocument, provider.FlavorData); if (sigError is not null) { - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { EventType = AuditEvents.SamlSignatureRejected, Severity = AuditSeverity.Warning, @@ -227,7 +227,7 @@ public async Task HandleAcsAsync( AuthenticationMethod = "saml", OutcomeCode = AuditOutcomes.Blocked, ReasonCode = sigError, - }); + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{Uri.EscapeDataString(sigError)}"); } @@ -261,16 +261,16 @@ public async Task HandleAcsAsync( "SAML response rejected ({Reason}) for provider {Id} — InResponseTo={InResponseTo}", reason, provider.LoginProviderId, saml2Response.InResponseToAsString); - securityAudit.Record(new SecurityAuditRecord + await securityAudit.RecordIncidentAsync(new SecurityAuditRecord { - EventType = AuditEvents.ExternalLoginRejected, + EventType = AuditEvents.ExternalLoginProtocolRejected, Severity = AuditSeverity.Warning, LoginProviderId = provider.LoginProviderId, IpAddress = ip, AuthenticationMethod = "saml", OutcomeCode = AuditOutcomes.Rejected, ReasonCode = $"request-correlation:{reason}", - }); + }, ct); ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.External, ModgudMeters.LoginOutcome.Failure); return Results.Redirect($"/login?error=saml-{reason}"); } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs index c20aba04..0a4e25d1 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/Saml/SamlMetadataRefreshService.cs @@ -84,9 +84,9 @@ private async Task TickAsync(CancellationToken ct) if (refreshed > 0 || failed > 0) { - securityAudit.RecordPlatform(new PlatformAuditRecord + securityAudit.RecordPlatformTelemetry(new PlatformAuditRecord { - EventType = AuditEvents.SamlMetadataRefreshed, + EventType = AuditEvents.SamlMetadataRefreshCompleted, OutcomeCode = failed == 0 ? AuditOutcomes.Succeeded : AuditOutcomes.Completed, ReasonCode = failed == 0 ? null : "partial-failure", OperationCode = "refresh-due-providers", diff --git a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs index 9ec968e5..632c1f55 100644 --- a/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs +++ b/src/dotnet/Modgud.Authentication/Identity/LoginProviders/Saml/SamlSpCertificateService.cs @@ -212,9 +212,7 @@ public async Task RotateAsync(CancellationToken ct = default) } _session.Store(doc); - await _session.SaveChangesAsync(ct); - - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.StoreRequired(_session, new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, RealmSlug = realmSlug, @@ -223,6 +221,7 @@ public async Task RotateAsync(CancellationToken ct = default) KeyId = doc.ActiveCertThumbprint, EffectiveAt = doc.ActiveCertNotAfter, }); + await _session.SaveChangesAsync(ct); return newCert; } @@ -284,9 +283,7 @@ private async Task LoadOrCreateAsync(CancellationToke }; _session.Store(doc); - await _session.SaveChangesAsync(ct); - - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.StoreRequired(_session, new SecurityAuditRecord { EventType = AuditEvents.SamlCertRotated, RealmSlug = realmSlug, @@ -295,6 +292,7 @@ private async Task LoadOrCreateAsync(CancellationToke KeyId = doc.ActiveCertThumbprint, EffectiveAt = doc.ActiveCertNotAfter, }); + await _session.SaveChangesAsync(ct); return doc; } diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index cc5cfbd4..81c85fac 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -4,6 +4,7 @@ using Modgud.Application.DTOs.Realms; using Modgud.Authentication.SelfRegistration.Captcha; using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; using ErrorOr; using Marten; using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; @@ -26,7 +27,8 @@ public interface IRealmSettingsService public sealed class RealmSettingsService( IDocumentSession session, - CaptchaSecretStore captchaStore) : IRealmSettingsService + CaptchaSecretStore captchaStore, + ISecurityAuditLog? securityAudit = null) : IRealmSettingsService { public async Task LoadAsync(CancellationToken ct = default) { @@ -52,6 +54,8 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d Id = RealmSettingsDoc.SingletonId, CreatedAt = DateTimeOffset.UtcNow, }; + var previousSecurityRetentionDays = + doc.Audit?.SecurityRetentionDays ?? AuditSettings.Defaults.SecurityRetentionDays; if (dto.SelfRegistration is not null) { @@ -131,6 +135,24 @@ public async Task> PatchAsync(UpdateRealmSettingsDto d if (!isCreate) doc.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); + if (doc.Audit?.SecurityRetentionDays is { } retentionDays + && retentionDays != previousSecurityRetentionDays) + { + if (securityAudit is null) + { + throw new InvalidOperationException( + "Changing security retention requires an audit-capable RealmSettingsService."); + } + + securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.SecurityRetentionChanged, + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "change-retention", + RetentionDays = retentionDays, + }); + } await session.SaveChangesAsync(ct); return ToDto(doc); diff --git a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs index 2e809939..fb96fae3 100644 --- a/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs +++ b/src/dotnet/Modgud.Authentication/Setup/PendingAdminInviteService.cs @@ -133,6 +133,17 @@ public async Task IssueAsync( IssuedBy = issuedBy, }; session.Store(invite); + securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.BootstrapInviteIssued, + CaptureRequestContext = false, + ActorKind = issuedBy is null + ? AuditActorKind.System + : AuditActorKind.ControlPlane, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "issue", + EffectiveAt = invite.ExpiresAt, + }); await session.SaveChangesAsync(ct); var url = BuildMagicLinkUrl(realm, token); @@ -168,16 +179,6 @@ await emailService.SendTemplatedEmailAsync( realm.Slug, LogPiiMasking.MaskEmail(normalizedEmail)); } - securityAudit.Record(new SecurityAuditRecord - { - EventType = AuditEvents.BootstrapInviteIssued, - ActorKind = AuditActorKind.AnonymousIdentifier, - UnknownIdentifier = normalizedEmail, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "issue", - EffectiveAt = invite.ExpiresAt, - }); - return new IssuedInvite(invite.Id, token, url, invite.ExpiresAt, normalizedEmail, normalizedUserName); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs new file mode 100644 index 00000000..0d23d55c --- /dev/null +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditDurability.cs @@ -0,0 +1,82 @@ +namespace Modgud.Infrastructure.Audit; + +/// +/// Delivery and persistence contract for streamless security/operations events. +/// The class is part of the event taxonomy so a call site cannot silently choose +/// a weaker guarantee than the event requires. +/// +public enum AuditDurabilityClass +{ + /// + /// Privileged or irreversible state transition. It must be persisted, or + /// enrolled in the same transactional outbox as the state change. + /// + Required, + + /// + /// Individual takeover/tamper incident without a normal state transaction. + /// The rejecting request waits for durable persistence. + /// + Incident, + + /// + /// Potentially attacker-amplified signal. Raw occurrences may be dropped or + /// sampled, while bounded batches are persisted as count aggregates. + /// + Abuse, + + /// + /// Reconstructable operational information. Explicitly best-effort. + /// + Telemetry, +} + +public static class AuditDurability +{ + public static AuditDurabilityClass Classify(string eventType) => eventType switch + { + AuditEvents.RefreshTokenReuseDetected or + AuditEvents.AuditLogExported or + AuditEvents.SecurityRetentionChanged or + AuditEvents.SigningKeyRotated or + AuditEvents.SamlCertRotated or + AuditEvents.SamlSigningCertificatesChanged or + AuditEvents.RecoveryCliInvoked or + AuditEvents.RealmProvisioned or + AuditEvents.RealmAdopted or + AuditEvents.ControlPlaneTransferred or + AuditEvents.ControlPlaneRealmOperation or + AuditEvents.BootstrapInviteIssued or + AuditEvents.DcrClientRegistered + => AuditDurabilityClass.Required, + + AuditEvents.ExternalLoginProtocolRejected or + AuditEvents.SamlSignatureRejected or + AuditEvents.IdentityHijackBlocked or + AuditEvents.JitEmailConflict or + AuditEvents.PrivilegeEscalationBlocked + => AuditDurabilityClass.Incident, + + AuditEvents.LoginFailed or + AuditEvents.LoginFailedUnknownUser or + AuditEvents.MagicLinkInvalid or + AuditEvents.ExternalLoginPolicyRejected or + AuditEvents.RateLimitTriggered or + AuditEvents.DcrRegistrationRejected or + AuditEvents.BootstrapInviteRejected + => AuditDurabilityClass.Abuse, + + AuditEvents.ExternalLoginConfigurationError or + AuditEvents.SigningKeyPurged or + AuditEvents.SamlMetadataRefreshCompleted or + AuditEvents.AccountLifecycleSwept or + AuditEvents.DcrClientFirstUsed or + AuditEvents.DcrClientGarbageCollected + => AuditDurabilityClass.Telemetry, + + _ => throw new ArgumentOutOfRangeException( + nameof(eventType), + eventType, + "The streamless audit event has no durability classification."), + }; +} diff --git a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs index 27009891..671f3e47 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/AuditEvents.cs @@ -80,12 +80,23 @@ public static class AuditEvents /// Carries Ip. public const string MagicLinkInvalid = "security.magic_link_invalid"; - /// An external/federation login was rejected before any user link — - /// domain allowlist, JIT disabled, inactive user, malformed token, or a - /// misconfigured provider. Reason disambiguates. Covers the SAML - /// protocol gates (no metadata, no SSO endpoint, context-build / response-read - /// failure, non-success status) as well as the OIDC/processor rejections. - public const string ExternalLoginRejected = "security.external_login_rejected"; + /// An external/federation response was rejected because its protocol + /// shape, signature-independent validation or request correlation was invalid. + /// This is a durable security incident, not a policy decision. + public const string ExternalLoginProtocolRejected = + "security.external_login_protocol_rejected"; + + /// An otherwise valid external identity was rejected by realm policy: + /// domain allowlist, JIT disabled or inactive/deleted user. Individual attempts + /// are abuse telemetry and are durably aggregated. + public const string ExternalLoginPolicyRejected = + "security.external_login_policy_rejected"; + + /// An external login could not start because provider metadata, + /// endpoints or configuration were unavailable. Operational telemetry rather + /// than a security incident. + public const string ExternalLoginConfigurationError = + "ops.external_login_configuration_error"; /// A SAML response failed the admin-required signature check /// (response/assertion unsigned). A distinct tamper / signature-wrapping @@ -135,16 +146,22 @@ public static class AuditEvents /// A realm signing key was rotated by an admin (tenant-visible). public const string SigningKeyRotated = "ops.signing_key_rotated"; - /// The signing-key janitor purged expired retired keys (platform-only). + /// The owning realm's signing-key janitor purged expired retired keys. public const string SigningKeyPurged = "ops.signing_key_purged"; /// A realm's SAML SP certificate was rotated or first generated /// (tenant-visible — a realm-relevant trust change). public const string SamlCertRotated = "ops.saml_cert_rotated"; - /// Background SAML metadata refresh tick / IdP signing-cert change - /// (platform-only). - public const string SamlMetadataRefreshed = "ops.saml_metadata_refreshed"; + /// Background SAML metadata refresh summary. Operational telemetry; + /// no trust-material change is represented by this event. + public const string SamlMetadataRefreshCompleted = + "ops.saml_metadata_refresh_completed"; + + /// The trusted IdP signing-certificate set changed after a metadata + /// refresh. This trust-boundary change requires a durable audit record. + public const string SamlSigningCertificatesChanged = + "ops.saml_signing_certificates_changed"; /// A recovery-CLI operation was invoked (filesystem-trust, control-plane /// only). Reason carries the specific operation + parameters. @@ -166,11 +183,12 @@ public static class AuditEvents public const string ControlPlaneRealmOperation = "ops.control_plane_realm_operation"; /// A per-realm account-lifecycle sweep ran (reminders / self-erase / - /// auto-purge counts). Platform-only operational summary. + /// auto-purge counts). Realm-owned operational summary. public const string AccountLifecycleSwept = "ops.account_lifecycle_swept"; /// A bootstrap-admin invite was issued (tenant-visible realm-init). - /// Any email is masked at the call site. + /// The recipient remains in the short-lived invite document; the durable + /// audit row deliberately carries no recipient PII. public const string BootstrapInviteIssued = "ops.bootstrap_invite_issued"; /// A DCR client was registered (tenant-visible). diff --git a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs index 52248778..aaae65c0 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/ISecurityAuditLog.cs @@ -1,3 +1,5 @@ +using Marten; + namespace Modgud.Infrastructure.Audit; /// @@ -41,6 +43,8 @@ public sealed record SecurityAuditRecord public int? ReusedCount { get; init; } public int? RetentionDays { get; init; } public DateTimeOffset? EffectiveAt { get; init; } + public DateTimeOffset? FirstObservedAt { get; init; } + public DateTimeOffset? LastObservedAt { get; init; } } /// @@ -66,12 +70,43 @@ public sealed record PlatformAuditRecord } /// -/// Best-effort, non-blocking security-event sink. Realm and platform records use -/// separate methods and payload types so storage ownership and the PII boundary -/// cannot be selected through a boolean flag. +/// Classified streamless audit sink. Required changes and individual incidents +/// wait for durable persistence. Abuse signals are bounded and aggregated. +/// Reconstructable operations telemetry remains explicitly best-effort. /// public interface ISecurityAuditLog { - void Record(SecurityAuditRecord record); - void RecordPlatform(PlatformAuditRecord record); + ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default); + + /// + /// Adds a required realm event to an existing Marten unit of work. The + /// caller's next commits the + /// business state and audit row atomically. + /// + void StoreRequired( + IDocumentSession session, + SecurityAuditRecord record); + + ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default); + + void RecordAbuse(SecurityAuditRecord record); + void RecordTelemetry(SecurityAuditRecord record); + + ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default); + + /// + /// Adds a required deployment-wide event to the caller's Global Store unit + /// of work so business state and audit row commit atomically. + /// + void StorePlatformRequired( + IDocumentSession session, + PlatformAuditRecord record); + + void RecordPlatformTelemetry(PlatformAuditRecord record); } diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs index 888b1d3d..a778024e 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditEvents.cs @@ -43,6 +43,8 @@ public sealed class RealmSecurityAuditEvent public int? ReusedCount { get; init; } public int? RetentionDays { get; init; } public DateTimeOffset? EffectiveAt { get; init; } + public DateTimeOffset? FirstObservedAt { get; init; } + public DateTimeOffset? LastObservedAt { get; init; } } /// diff --git a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs index ee6657a0..b915f405 100644 --- a/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs +++ b/src/dotnet/Modgud.Infrastructure/Audit/SecurityAuditLog.cs @@ -15,16 +15,23 @@ namespace Modgud.Infrastructure.Audit; /// -/// Best-effort in-process buffer for realm-owned security events and PII-free -/// platform events. The background writer routes each envelope to its owning -/// physical store. F7 (durable delivery) remains a separate decision. +/// Classified streamless audit sink. Required and incident records are written +/// synchronously before the caller can report success/rejection. Abuse records +/// use a bounded aggregating buffer, and reconstructable telemetry uses the same +/// buffer with an explicit best-effort contract. /// -public sealed class SecurityAuditLog(IHttpContextAccessor httpContextAccessor) : ISecurityAuditLog +public sealed class SecurityAuditLog( + IHttpContextAccessor httpContextAccessor, + IServiceScopeFactory scopeFactory) : ISecurityAuditLog { private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(50_000) { - FullMode = BoundedChannelFullMode.DropWrite, + // We intentionally use TryWrite below. Wait mode makes TryWrite + // return false when full, allowing us to count the shed raw + // occurrence precisely; DropWrite reports acceptance even when it + // discards the item. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true, }); @@ -33,39 +40,64 @@ public sealed class SecurityAuditLog(IHttpContextAccessor httpContextAccessor) : internal ChannelReader Reader => _channel.Reader; internal long ReadAndResetDropped() => Interlocked.Exchange(ref _dropped, 0); - public void Record(SecurityAuditRecord record) + public ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default) + => PersistRealmNowAsync(record, AuditDurabilityClass.Required, ct); + + public void StoreRequired( + IDocumentSession session, + SecurityAuditRecord record) + { + EnsureClass(record.EventType, AuditDurabilityClass.Required); + var envelope = CaptureRealmEnvelope(record, AuditDurabilityClass.Required); + SecurityAuditPersistence.StoreRequired(session, envelope); + } + + public ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default) + => PersistRealmNowAsync(record, AuditDurabilityClass.Incident, ct); + + public void RecordAbuse(SecurityAuditRecord record) + => EnqueueRealm(record, AuditDurabilityClass.Abuse); + + public void RecordTelemetry(SecurityAuditRecord record) + => EnqueueRealm(record, AuditDurabilityClass.Telemetry); + + public ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default) + => PersistPlatformNowAsync(record, AuditDurabilityClass.Required, ct); + + public void StorePlatformRequired( + IDocumentSession session, + PlatformAuditRecord record) { + EnsureClass(record.EventType, AuditDurabilityClass.Required); + SecurityAuditPersistence.StorePlatformRequired( + session, + SecurityAuditEnvelope.ForPlatform( + record with + { + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + AuditDurabilityClass.Required)); + } + + public void RecordPlatformTelemetry(PlatformAuditRecord record) + { + EnsureClass(record.EventType, AuditDurabilityClass.Telemetry); try { - var http = record.CaptureRequestContext - ? httpContextAccessor.HttpContext - : null; - var subject = record.ActorSubjectId ?? - (record.ActorKind is null or AuditActorKind.User ? TryGetSubject(http) : null); - var ip = record.IpAddress ?? http?.Connection.RemoteIpAddress?.ToString(); - var requestUserAgent = http?.Request.Headers.UserAgent.ToString(); - var userAgent = record.UserAgent ?? - (string.IsNullOrWhiteSpace(requestUserAgent) ? null : requestUserAgent); - var correlationId = record.CorrelationId ?? CurrentCorrelationId(http); - var actorKind = record.ActorKind - ?? (subject is not null - ? AuditActorKind.User - : record.UnknownIdentifier is not null - ? AuditActorKind.AnonymousIdentifier - : record.OAuthClientId is not null - ? AuditActorKind.OAuthClient - : AuditActorKind.System); - - Enqueue(SecurityAuditEnvelope.ForRealm( - record.RealmSlug ?? TenantContext.Current, + Enqueue(SecurityAuditEnvelope.ForPlatform( record with { - ActorSubjectId = subject, - IpAddress = ip, - UserAgent = userAgent, - CorrelationId = correlationId, - ActorKind = actorKind, - })); + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + AuditDurabilityClass.Telemetry)); } catch { @@ -73,14 +105,50 @@ record with } } - public void RecordPlatform(PlatformAuditRecord record) + private async ValueTask PersistRealmNowAsync( + SecurityAuditRecord record, + AuditDurabilityClass expected, + CancellationToken ct) { + EnsureClass(record.EventType, expected); + var envelope = CaptureRealmEnvelope(record, expected); + using var scope = scopeFactory.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + [envelope], + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + ct); + } + + private async ValueTask PersistPlatformNowAsync( + PlatformAuditRecord record, + AuditDurabilityClass expected, + CancellationToken ct) + { + EnsureClass(record.EventType, expected); + var envelope = SecurityAuditEnvelope.ForPlatform( + record with + { + CorrelationId = record.CorrelationId + ?? CurrentCorrelationId(httpContextAccessor.HttpContext), + }, + expected); + using var scope = scopeFactory.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + [envelope], + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + ct); + } + + private void EnqueueRealm( + SecurityAuditRecord record, + AuditDurabilityClass expected) + { + EnsureClass(record.EventType, expected); try { - Enqueue(SecurityAuditEnvelope.ForPlatform(record with - { - CorrelationId = record.CorrelationId ?? CurrentCorrelationId(httpContextAccessor.HttpContext), - })); + Enqueue(CaptureRealmEnvelope(record, expected)); } catch { @@ -88,6 +156,41 @@ public void RecordPlatform(PlatformAuditRecord record) } } + private SecurityAuditEnvelope CaptureRealmEnvelope( + SecurityAuditRecord record, + AuditDurabilityClass durabilityClass) + { + var http = record.CaptureRequestContext + ? httpContextAccessor.HttpContext + : null; + var subject = record.ActorSubjectId ?? + (record.ActorKind is null or AuditActorKind.User ? TryGetSubject(http) : null); + var ip = record.IpAddress ?? http?.Connection.RemoteIpAddress?.ToString(); + var requestUserAgent = http?.Request.Headers.UserAgent.ToString(); + var userAgent = record.UserAgent ?? + (string.IsNullOrWhiteSpace(requestUserAgent) ? null : requestUserAgent); + var actorKind = record.ActorKind + ?? (subject is not null + ? AuditActorKind.User + : record.UnknownIdentifier is not null + ? AuditActorKind.AnonymousIdentifier + : record.OAuthClientId is not null + ? AuditActorKind.OAuthClient + : AuditActorKind.System); + + return SecurityAuditEnvelope.ForRealm( + record.RealmSlug ?? TenantContext.Current, + record with + { + ActorSubjectId = subject, + IpAddress = ip, + UserAgent = userAgent, + CorrelationId = record.CorrelationId ?? CurrentCorrelationId(http), + ActorKind = actorKind, + }, + durabilityClass); + } + private void Enqueue(SecurityAuditEnvelope envelope) { if (!_channel.Writer.TryWrite(envelope)) @@ -108,7 +211,26 @@ public async Task FlushAsync( batch.Add(entry); if (batch.Count > 0) - await SecurityAuditPersistence.PersistAsync(batch, realmStore, globalStore, ct); + { + var consolidated = SecurityAuditBatching.ConsolidateAbuse(batch); + await SecurityAuditPersistence.PersistAsync( + consolidated, + realmStore, + globalStore, + ct); + } + } + + private static void EnsureClass( + string eventType, + AuditDurabilityClass expected) + { + var actual = AuditDurability.Classify(eventType); + if (actual != expected) + { + throw new InvalidOperationException( + $"Audit event '{eventType}' is classified as {actual}, not {expected}."); + } } private static Guid? TryGetSubject(HttpContext? http) @@ -125,15 +247,98 @@ public async Task FlushAsync( internal sealed record SecurityAuditEnvelope { + public Guid Id { get; init; } = Guid.NewGuid(); + public DateTimeOffset CapturedAt { get; init; } = DateTimeOffset.UtcNow; + public required AuditDurabilityClass DurabilityClass { get; init; } public required string? RealmSlug { get; init; } public SecurityAuditRecord? RealmRecord { get; init; } public PlatformAuditRecord? PlatformRecord { get; init; } - public static SecurityAuditEnvelope ForRealm(string realmSlug, SecurityAuditRecord record) - => new() { RealmSlug = realmSlug, RealmRecord = record }; + public static SecurityAuditEnvelope ForRealm( + string realmSlug, + SecurityAuditRecord record, + AuditDurabilityClass durabilityClass) + => new() + { + RealmSlug = realmSlug, + RealmRecord = record, + DurabilityClass = durabilityClass, + }; - public static SecurityAuditEnvelope ForPlatform(PlatformAuditRecord record) - => new() { RealmSlug = null, PlatformRecord = record }; + public static SecurityAuditEnvelope ForPlatform( + PlatformAuditRecord record, + AuditDurabilityClass durabilityClass) + => new() + { + RealmSlug = null, + PlatformRecord = record, + DurabilityClass = durabilityClass, + }; +} + +internal static class SecurityAuditBatching +{ + public static IReadOnlyCollection ConsolidateAbuse( + IReadOnlyCollection batch) + { + var result = batch + .Where(x => x.DurabilityClass != AuditDurabilityClass.Abuse) + .ToList(); + + foreach (var group in batch + .Where(x => x.DurabilityClass == AuditDurabilityClass.Abuse) + .GroupBy(AbuseKey.From)) + { + var first = group.First(); + var record = first.RealmRecord!; + result.Add(SecurityAuditEnvelope.ForRealm( + first.RealmSlug!, + record with + { + Count = group.Sum(x => x.RealmRecord!.Count ?? 1), + FirstObservedAt = group.Min(x => x.CapturedAt), + LastObservedAt = group.Max(x => x.CapturedAt), + }, + AuditDurabilityClass.Abuse)); + } + + return result; + } + + private sealed record AbuseKey( + string RealmSlug, + string EventType, + string? ReasonCode, + string? OperationCode, + AuditActorKind? ActorKind, + Guid? ActorSubjectId, + Guid? TargetSubjectId, + string? UnknownIdentifier, + string? IpAddress, + string? OAuthClientId, + Guid? ApplicationId, + Guid? LoginProviderId, + string? AuthenticationMethod) + { + public static AbuseKey From(SecurityAuditEnvelope envelope) + { + var record = envelope.RealmRecord!; + return new( + envelope.RealmSlug!, + record.EventType, + record.ReasonCode, + record.OperationCode, + record.ActorKind, + record.ActorSubjectId, + record.TargetSubjectId, + record.UnknownIdentifier, + record.IpAddress, + record.OAuthClientId, + record.ApplicationId, + record.LoginProviderId, + record.AuthenticationMethod); + } + } } internal static class SecurityAuditPersistence @@ -151,9 +356,11 @@ public static async Task PersistAsync( .Where(x => x.RealmRecord is not null) .GroupBy(x => x.RealmSlug!, StringComparer.OrdinalIgnoreCase)) { - var key = await GetOrCreateFingerprintKeyAsync(realmStore, realmGroup.Key, ct); + var key = realmGroup.Any(x => x.RealmRecord!.UnknownIdentifier is not null) + ? await GetOrCreateFingerprintKeyAsync(realmStore, realmGroup.Key, ct) + : null; var events = realmGroup - .Select(x => ToRealmEvent(x.RealmRecord!, key)) + .Select(x => ToRealmEvent(x, key)) .ToArray(); await using var session = realmStore.LightweightSession(realmGroup.Key); @@ -163,7 +370,7 @@ public static async Task PersistAsync( var platformEvents = batch .Where(x => x.PlatformRecord is not null) - .Select(x => ToPlatformEvent(x.PlatformRecord!)) + .Select(ToPlatformEvent) .ToArray(); if (platformEvents.Length > 0) @@ -174,10 +381,47 @@ public static async Task PersistAsync( } } - private static RealmSecurityAuditEvent ToRealmEvent(SecurityAuditRecord record, byte[] key) - => new() + public static void StoreRequired( + IDocumentSession session, + SecurityAuditEnvelope envelope) + { + var record = envelope.RealmRecord + ?? throw new ArgumentException("A realm audit envelope is required.", nameof(envelope)); + if (record.UnknownIdentifier is not null) { - Timestamp = DateTimeOffset.UtcNow, + throw new InvalidOperationException( + "Required events with an unknown identifier must use RecordRequiredAsync " + + "so the identifier can be fingerprinted with the realm-owned key."); + } + + if (!string.Equals(session.TenantId, envelope.RealmSlug, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Audit realm '{envelope.RealmSlug}' does not match the Marten session tenant '{session.TenantId}'."); + } + + session.Store(ToRealmEvent(envelope, key: null)); + } + + public static void StorePlatformRequired( + IDocumentSession session, + SecurityAuditEnvelope envelope) + { + if (envelope.PlatformRecord is null) + throw new ArgumentException("A platform audit envelope is required.", nameof(envelope)); + + session.Store(ToPlatformEvent(envelope)); + } + + private static RealmSecurityAuditEvent ToRealmEvent( + SecurityAuditEnvelope envelope, + byte[]? key) + { + var record = envelope.RealmRecord!; + return new() + { + Id = envelope.Id, + Timestamp = envelope.CapturedAt, Category = AuditEvents.CategoryOf(record.EventType), EventType = record.EventType, Severity = record.Severity, @@ -186,7 +430,10 @@ private static RealmSecurityAuditEvent ToRealmEvent(SecurityAuditRecord record, TargetSubjectId = record.TargetSubjectId, UnknownIdentifierFingerprint = record.UnknownIdentifier is null ? null - : Fingerprint(record.UnknownIdentifier, key), + : Fingerprint( + record.UnknownIdentifier, + key ?? throw new InvalidOperationException( + "An audit fingerprint key is required for an unknown identifier.")), IpAddress = record.IpAddress, UserAgent = record.UserAgent, OAuthClientId = record.OAuthClientId, @@ -210,12 +457,18 @@ private static RealmSecurityAuditEvent ToRealmEvent(SecurityAuditRecord record, ReusedCount = record.ReusedCount, RetentionDays = record.RetentionDays, EffectiveAt = record.EffectiveAt, + FirstObservedAt = record.FirstObservedAt, + LastObservedAt = record.LastObservedAt, }; + } - private static PlatformAuditEvent ToPlatformEvent(PlatformAuditRecord record) - => new() + private static PlatformAuditEvent ToPlatformEvent(SecurityAuditEnvelope envelope) + { + var record = envelope.PlatformRecord!; + return new() { - Timestamp = DateTimeOffset.UtcNow, + Id = envelope.Id, + Timestamp = envelope.CapturedAt, Category = AuditEvents.CategoryOf(record.EventType), EventType = record.EventType, Severity = record.Severity, @@ -231,6 +484,7 @@ private static PlatformAuditEvent ToPlatformEvent(PlatformAuditRecord record) RetentionDays = record.RetentionDays, EffectiveAt = record.EffectiveAt, }; + } private static string Fingerprint(string identifier, byte[] key) { @@ -291,7 +545,7 @@ public sealed class SecurityAuditWriter( SecurityAuditLog log, ILogger logger) : BackgroundService { - private const int MaxBatch = 256; + private const int MaxBatch = 4_096; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -305,6 +559,61 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (batch.Count == 0) continue; + // Give attacker-amplified signals a short coalescing window. This + // turns a credential-stuffing burst into a handful of count rows + // instead of one database write per request. + await Task.Delay(TimeSpan.FromMilliseconds(250), stoppingToken); + while (batch.Count < MaxBatch && reader.TryRead(out var entry)) + batch.Add(entry); + + var consolidated = SecurityAuditBatching.ConsolidateAbuse(batch); + var abuse = consolidated + .Where(x => x.DurabilityClass == AuditDurabilityClass.Abuse) + .ToArray(); + var telemetry = consolidated + .Where(x => x.DurabilityClass == AuditDurabilityClass.Telemetry) + .ToArray(); + + if (abuse.Length > 0) + await PersistAbuseWithRetryAsync(abuse, stoppingToken); + + if (telemetry.Length > 0) + { + try + { + using var scope = services.CreateScope(); + await SecurityAuditPersistence.PersistAsync( + telemetry, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError( + ex, + "Failed to persist {Count} best-effort audit telemetry record(s)", + telemetry.Length); + } + } + + var dropped = log.ReadAndResetDropped(); + if (dropped > 0) + { + logger.LogWarning( + "Security audit buffer shed {Dropped} abuse/telemetry occurrence(s) — channel full", + dropped); + } + } + } + + private async Task PersistAbuseWithRetryAsync( + IReadOnlyCollection batch, + CancellationToken ct) + { + var delay = TimeSpan.FromMilliseconds(250); + while (!ct.IsCancellationRequested) + { try { using var scope = services.CreateScope(); @@ -312,16 +621,19 @@ await SecurityAuditPersistence.PersistAsync( batch, scope.ServiceProvider.GetRequiredService(), scope.ServiceProvider.GetRequiredService(), - stoppingToken); + ct); + return; } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogError(ex, "Failed to persist {Count} security audit entries", batch.Count); + logger.LogError( + ex, + "Failed to persist {Count} aggregated abuse signal(s); retrying in {Delay}", + batch.Count, + delay); + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); } - - var dropped = log.ReadAndResetDropped(); - if (dropped > 0) - logger.LogWarning("Security audit store shed {Dropped} record(s) — channel full", dropped); } } } diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs index 079c0f3a..497fd421 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/DcrLastUsedTrackerHandler.cs @@ -78,7 +78,7 @@ public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext c if (isFirstUse) { - _securityAudit.Record(new SecurityAuditRecord + _securityAudit.RecordTelemetry(new SecurityAuditRecord { EventType = AuditEvents.DcrClientFirstUsed, ActorKind = AuditActorKind.OAuthClient, diff --git a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs index 3b9eed4e..7aeb774b 100644 --- a/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs +++ b/src/dotnet/Modgud.Infrastructure/OpenIddict/RefreshTokenReuseAuditHandler.cs @@ -126,7 +126,7 @@ public async ValueTask HandleAsync(ValidateTokenContext context) "Refresh token reuse detected for user {UserId}, client {ClientId}, authorization {AuthorizationId} — {FamilySize} token(s) about to be revoked", subject, clientId, authorizationId, familySize); - _securityAudit.Record(new SecurityAuditRecord + await _securityAudit.RecordRequiredAsync(new SecurityAuditRecord { EventType = AuditEvents.RefreshTokenReuseDetected, Severity = AuditSeverity.Warning, @@ -137,7 +137,7 @@ public async ValueTask HandleAsync(ValidateTokenContext context) OutcomeCode = AuditOutcomes.Blocked, OperationCode = "revoke-token-family", Count = familySize, - }); + }, context.CancellationToken); // Keep higher-level session models in sync with OpenIddict's imminent // token-family teardown. Observer failures must never interrupt the diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs index 68da04f8..b060e342 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmKeyStore.cs @@ -3,6 +3,7 @@ using Modgud.Domain.Realms; using Marten; using Microsoft.IdentityModel.Tokens; +using Modgud.Infrastructure.Audit; namespace Modgud.Infrastructure.Realms; @@ -66,6 +67,7 @@ public sealed class RealmKeyStore : IRealmKeyStore private readonly IDocumentStore _store; private readonly TimeProvider _clock; + private readonly ISecurityAuditLog _securityAudit; // Active signing credentials per realm, with the kid they were built from // and when they were loaded — so a stale entry can be re-validated against @@ -102,10 +104,14 @@ private sealed record ActiveEntry(SigningCredentials Creds, string Kid, DateTime private sealed record VerificationSet(IReadOnlyList Keys, DateTimeOffset ValidUntil); - public RealmKeyStore(IDocumentStore store, TimeProvider clock) + public RealmKeyStore( + IDocumentStore store, + TimeProvider clock, + ISecurityAuditLog securityAudit) { _store = store; _clock = clock; + _securityAudit = securityAudit; } public async Task GetActiveSigningCredentialsAsync( @@ -257,6 +263,15 @@ public async Task RotateAsync( // Generate fresh key and persist. var fresh = CreateNewKeyDocument(realmSlug); session.Store(fresh); + _securityAudit.StoreRequired(session, new SecurityAuditRecord + { + EventType = AuditEvents.SigningKeyRotated, + RealmSlug = realmSlug, + Severity = AuditSeverity.Warning, + OutcomeCode = AuditOutcomes.Succeeded, + OperationCode = "rotate", + KeyId = fresh.KeyId, + }); await session.SaveChangesAsync(ct); // Cache the fresh key as the active credentials DIRECTLY. Do NOT diff --git a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs index cb0e93e6..05ede8e8 100644 --- a/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs +++ b/src/dotnet/Modgud.Infrastructure/Realms/RealmProvisioningService.cs @@ -219,6 +219,14 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat csBuilder.Database = tenantDbName; var tenantCs = csBuilder.ConnectionString; + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + TargetRealmSlug = dto.Slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "provision-realm", + }, ct); + // Raw SQL: create the PostgreSQL database (DDL — cannot use Marten/parameters) var bootstrapBuilder = new NpgsqlConnectionStringBuilder(_masterCs.Value) { Database = "postgres" }; await using (var bootstrapConn = new NpgsqlConnection(bootstrapBuilder.ConnectionString)) @@ -242,13 +250,6 @@ public async Task> CreateRealmAsync(CreateRealmDto dto, Cancellat #pragma warning restore CA2100 await createDbCmd.ExecuteNonQueryAsync(ct); _logger.LogInformation("Created database {DbName} for realm {Slug}", tenantDbName, dto.Slug); - _securityAudit.RecordPlatform(new PlatformAuditRecord - { - EventType = AuditEvents.RealmProvisioned, - TargetRealmSlug = dto.Slug, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "create-database", - }); } } @@ -277,6 +278,13 @@ await ApplyTenantSchemaResilientlyAsync( }; session.Store(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + TargetRealmSlug = dto.Slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "provision-realm", + }); await session.SaveChangesAsync(ct); // Per-realm OAuth seeding — standard OIDC scopes land in the new tenant DB. @@ -483,6 +491,16 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT var mainDbName = csBuilder.Database!; var tenantDbName = $"{mainDbName}_{slug}"; + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "hard-delete", + ReasonCode = "operator-request", + }, ct); + // 1. Hand the tenant back to Marten. RemoveTenantAsync evicts it from the // tenancy's in-memory cache, disposes its Npgsql data source (gracefully // closing the pool before the drop) and deletes the registry row in @@ -518,16 +536,7 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT // 3. Remove the global Realm record and invalidate the cache so middleware // stops resolving the now-dropped realm. session.Delete(realm); - await session.SaveChangesAsync(ct); - _realmCache.Invalidate(); - await ReconcileJobSchedulesAsync(ct); - - _logger.LogWarning( - "Hard-deleted realm {Slug}: dropped tenant database {DbName} and removed the global Realm record. " + - "Irreversible — event streams, signing keys and the OpenIddict token store are gone.", - slug, tenantDbName); - - _securityAudit.RecordPlatform(new PlatformAuditRecord + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord { EventType = AuditEvents.RealmProvisioned, Severity = AuditSeverity.Warning, @@ -536,6 +545,14 @@ public async Task> HardDeleteRealmAsync(string slug, CancellationT OperationCode = "hard-delete", ReasonCode = "operator-request", }); + await session.SaveChangesAsync(ct); + _realmCache.Invalidate(); + await ReconcileJobSchedulesAsync(ct); + + _logger.LogWarning( + "Hard-deleted realm {Slug}: dropped tenant database {DbName} and removed the global Realm record. " + + "Irreversible — event streams, signing keys and the OpenIddict token store are gone.", + slug, tenantDbName); return true; } @@ -563,6 +580,15 @@ public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken c } session.Delete(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmProvisioned, + Severity = AuditSeverity.Warning, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "rollback-provisioning", + ReasonCode = "bootstrap-invite-failed", + }); await session.SaveChangesAsync(ct); _realmCache.Invalidate(); @@ -572,15 +598,6 @@ public async Task RollbackProvisionedRealmAsync(string slug, CancellationToken c "Rolled back partially-provisioned realm {Slug} after a post-create bootstrap failure. " + "The tenant database is left in place for idempotent reuse on retry.", slug); - _securityAudit.RecordPlatform(new PlatformAuditRecord - { - EventType = AuditEvents.RealmProvisioned, - Severity = AuditSeverity.Warning, - TargetRealmSlug = slug, - OutcomeCode = AuditOutcomes.Completed, - OperationCode = "rollback-provisioning", - ReasonCode = "bootstrap-invite-failed", - }); } public async Task EnsureSystemRealmExistsAsync(CancellationToken ct = default) @@ -710,6 +727,15 @@ public async Task> TransferControlPlaneAsync( target.UpdatedAt = DateTimeOffset.UtcNow; session.Store(target); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.ControlPlaneTransferred, + Severity = AuditSeverity.Warning, + TargetRealmSlug = targetSlug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "transfer", + Count = otherHolders.Count, + }); await session.SaveChangesAsync(ct); // The flag move is committed. Invalidate the cache NOW (load-bearing — @@ -743,16 +769,6 @@ await AppRealmSeeder.SeedAsync( _logger.LogWarning( "Control plane transferred to realm {Slug} (cleared {Count} previous holder(s))", targetSlug, otherHolders.Count); - _securityAudit.RecordPlatform(new PlatformAuditRecord - { - EventType = AuditEvents.ControlPlaneTransferred, - Severity = AuditSeverity.Warning, - TargetRealmSlug = targetSlug, - OutcomeCode = AuditOutcomes.Completed, - OperationCode = "transfer", - Count = otherHolders.Count, - }); - return target; } @@ -814,6 +830,14 @@ public async Task> AdoptExistingDatabaseAsync( } } + await _securityAudit.RecordPlatformRequiredAsync(new PlatformAuditRecord + { + EventType = AuditEvents.RealmAdopted, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Initiated, + OperationCode = "adopt-database", + }, ct); + // Register in Marten's tenant registry + apply schema idempotently // (existing data is preserved; this only adds missing tables/indexes). var tenancy = (Marten.Storage.MasterTableTenancy)_tenantedStore.Options.Tenancy; @@ -834,6 +858,13 @@ await ApplyTenantSchemaResilientlyAsync( CreatedAt = DateTimeOffset.UtcNow, }; session.Store(realm); + _securityAudit.StorePlatformRequired(session, new PlatformAuditRecord + { + EventType = AuditEvents.RealmAdopted, + TargetRealmSlug = slug, + OutcomeCode = AuditOutcomes.Completed, + OperationCode = "adopt-database", + }); await session.SaveChangesAsync(ct); // Idempotent catalog seeding — won't clobber existing rows in the @@ -850,13 +881,6 @@ await seederScope.ServiceProvider _realmCache.Invalidate(); await ReconcileJobSchedulesAsync(ct); _logger.LogInformation("Adopted existing database {DbName} as realm {Slug}", tenantDbName, slug); - _securityAudit.RecordPlatform(new PlatformAuditRecord - { - EventType = AuditEvents.RealmAdopted, - TargetRealmSlug = slug, - OutcomeCode = AuditOutcomes.Succeeded, - OperationCode = "adopt-database", - }); return realm; } diff --git a/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs b/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs new file mode 100644 index 00000000..ef049d36 --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Audit/AuditDurabilityTests.cs @@ -0,0 +1,60 @@ +using Modgud.Infrastructure.Audit; + +namespace Modgud.Tests.Unit.Audit; + +public class AuditDurabilityTests +{ + public static TheoryData ClassifiedEvents => new() + { + { AuditEvents.RefreshTokenReuseDetected, AuditDurabilityClass.Required }, + { AuditEvents.AuditLogExported, AuditDurabilityClass.Required }, + { AuditEvents.SecurityRetentionChanged, AuditDurabilityClass.Required }, + { AuditEvents.SigningKeyRotated, AuditDurabilityClass.Required }, + { AuditEvents.SamlCertRotated, AuditDurabilityClass.Required }, + { AuditEvents.SamlSigningCertificatesChanged, AuditDurabilityClass.Required }, + { AuditEvents.RecoveryCliInvoked, AuditDurabilityClass.Required }, + { AuditEvents.RealmProvisioned, AuditDurabilityClass.Required }, + { AuditEvents.RealmAdopted, AuditDurabilityClass.Required }, + { AuditEvents.ControlPlaneTransferred, AuditDurabilityClass.Required }, + { AuditEvents.ControlPlaneRealmOperation, AuditDurabilityClass.Required }, + { AuditEvents.BootstrapInviteIssued, AuditDurabilityClass.Required }, + { AuditEvents.DcrClientRegistered, AuditDurabilityClass.Required }, + + { AuditEvents.ExternalLoginProtocolRejected, AuditDurabilityClass.Incident }, + { AuditEvents.SamlSignatureRejected, AuditDurabilityClass.Incident }, + { AuditEvents.IdentityHijackBlocked, AuditDurabilityClass.Incident }, + { AuditEvents.JitEmailConflict, AuditDurabilityClass.Incident }, + { AuditEvents.PrivilegeEscalationBlocked, AuditDurabilityClass.Incident }, + + { AuditEvents.LoginFailed, AuditDurabilityClass.Abuse }, + { AuditEvents.LoginFailedUnknownUser, AuditDurabilityClass.Abuse }, + { AuditEvents.MagicLinkInvalid, AuditDurabilityClass.Abuse }, + { AuditEvents.ExternalLoginPolicyRejected, AuditDurabilityClass.Abuse }, + { AuditEvents.RateLimitTriggered, AuditDurabilityClass.Abuse }, + { AuditEvents.DcrRegistrationRejected, AuditDurabilityClass.Abuse }, + { AuditEvents.BootstrapInviteRejected, AuditDurabilityClass.Abuse }, + + { AuditEvents.ExternalLoginConfigurationError, AuditDurabilityClass.Telemetry }, + { AuditEvents.SigningKeyPurged, AuditDurabilityClass.Telemetry }, + { AuditEvents.SamlMetadataRefreshCompleted, AuditDurabilityClass.Telemetry }, + { AuditEvents.AccountLifecycleSwept, AuditDurabilityClass.Telemetry }, + { AuditEvents.DcrClientFirstUsed, AuditDurabilityClass.Telemetry }, + { AuditEvents.DcrClientGarbageCollected, AuditDurabilityClass.Telemetry }, + }; + + [Theory] + [MemberData(nameof(ClassifiedEvents))] + public void Streamless_events_have_an_explicit_delivery_contract( + string eventType, + AuditDurabilityClass expected) + { + Assert.Equal(expected, AuditDurability.Classify(eventType)); + } + + [Fact] + public void Unknown_event_cannot_silently_choose_a_weaker_contract() + { + Assert.Throws( + () => AuditDurability.Classify("security.unclassified")); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs index a58eb647..3577547f 100644 --- a/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/ExternalAuth/DynamicSamlSchemeManagerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Marten; using Microsoft.Extensions.Logging.Abstractions; using Modgud.Authentication.Api.ExternalAuth.Saml; using Modgud.Authentication.Domain.LoginProviders; @@ -36,8 +37,20 @@ private static DynamicSamlSchemeManager NewManager() => /// metadata-refresh audit record is exercised by the integration suite. private sealed class NoOpSecurityAuditLog : ISecurityAuditLog { - public void Record(SecurityAuditRecord record) { } - public void RecordPlatform(PlatformAuditRecord record) { } + public ValueTask RecordRequiredAsync( + SecurityAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void StoreRequired(IDocumentSession session, SecurityAuditRecord record) { } + public ValueTask RecordIncidentAsync( + SecurityAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void RecordAbuse(SecurityAuditRecord record) { } + public void RecordTelemetry(SecurityAuditRecord record) { } + public ValueTask RecordPlatformRequiredAsync( + PlatformAuditRecord record, + CancellationToken ct = default) => ValueTask.CompletedTask; + public void StorePlatformRequired(IDocumentSession session, PlatformAuditRecord record) { } + public void RecordPlatformTelemetry(PlatformAuditRecord record) { } } /// diff --git a/src/frontend-vue/src/views/admin/AuthLogView.vue b/src/frontend-vue/src/views/admin/AuthLogView.vue index a95e6918..69086987 100644 --- a/src/frontend-vue/src/views/admin/AuthLogView.vue +++ b/src/frontend-vue/src/views/admin/AuthLogView.vue @@ -33,6 +33,8 @@ interface SecurityLogEntry { OutcomeCode: string ReasonCode: string | null TargetRealmSlug: string | null + FirstObservedAt: string | null + LastObservedAt: string | null Message: string } From 41c04e4d09d4c293a1ba232fc322e71d7e1993b8 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 23 Jul 2026 19:10:57 +0200 Subject: [PATCH 07/34] fix: make realm admin roles explicitly unscoped --- README.md | 5 +- docs/admin/roles.md | 23 ++++---- docs/concepts/groups-and-authorization.md | 3 +- docs/getting-started/features.md | 2 +- docs/index.md | 2 +- docs/integrate/saas-walkthrough.md | 3 +- .../RolesEndpointsRobustnessTests.cs | 31 +++++++++++ .../Admin/Provisioning/RealmManifest.cs | 6 +-- .../Provisioning/RealmManifestExporter.cs | 12 +++-- .../Features/Roles/RoleAdminService.cs | 13 ++++- .../Features/Roles/RolesEndpoints.cs | 6 +-- .../Api/Admin/RecoveryCommands.cs | 5 +- .../Domain/LoginProviders/LoginProvider.cs | 5 +- .../Roles/PermissionRole.cs | 31 ++++++----- .../src/views/admin/AdminView.vue | 5 +- .../src/views/admin/role/RoleDetails.vue | 54 ++++++++++++------- src/landing-page/src/pages/index.astro | 2 +- 17 files changed, 140 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 60e1c6d3..5c52c86d 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ emission, full 2FA spectrum, GDPR self-service. database separation prevents query-level tenant mixing. - **Multi-app permission model** — Apps are first-class. Permissions are 2-segment (`:`) inside an app's catalog. Two - bypass tiers, no more. Roles bind to one App, groups carry a - `BoundTo` activation list. + bypass tiers, no more. Application roles bind to one App; a pure + `realm:admin` role is the explicit realm-local exception. Groups + carry a `BoundTo` activation list. - **Keycloak-style `resource_access` on UserInfo** — per-audience blocks with bypass pre-expansion and per-RS subset narrowing. A drop-in `IClaimsTransformation` library flattens the right block diff --git a/docs/admin/roles.md b/docs/admin/roles.md index 9789ff63..693eea81 100644 --- a/docs/admin/roles.md +++ b/docs/admin/roles.md @@ -1,6 +1,9 @@ # Roles -A **role** bundles permissions for one app. Users receive roles only through their [groups](./groups) — never directly. +An **application role** bundles permissions for exactly one app. A pure +`realm:admin` role is the explicit exception: it has no Application link or +catalog permissions and grants the bypass across every app in its own realm. +Users receive roles only through their [groups](./groups) — never directly. ![Roles list](/screenshots/admin-rollen-liste.png) @@ -42,7 +45,7 @@ The app is never part of the string — it comes from the role's Application link (or, for the built-in `modgud`/`control-plane` admin surfaces, from the endpoint being called). Plus two bypass tiers: -- **`realm:admin`** — realm-wide. The holder may do anything in any app. Set via the role's **Privileged role** flag, not a catalog entry. +- **`realm:admin`** — current-realm-wide. The holder may do anything in any app in this realm, but gains nothing in another realm. It is represented by a pure realm-admin role, not a catalog entry. - **`:admin`** — resource-wide, within the role's linked Application (e.g. `user:admin` bypasses both `user:read` and `user:write`). There is no app-wide bypass tier — bypass is either realm-wide or resource-wide, nothing in between. @@ -105,12 +108,12 @@ The modal has two tabs: - **Name** (unique per realm) - **Description** (optional) - **Application** — which app does this role belong to? Pick "— None - (realm-admin role)" for a pure bypass role (only meaningful together - with **Privileged role** below); otherwise a role belongs to exactly - one Application. -- **Privileged role** — a checkbox, independent of the Application - link. Grants `realm:admin` — the realm-wide bypass. Reserved for the - System Admin role. + (realm-admin role)" only for a pure bypass role; otherwise a role + belongs to exactly one Application. +- **Privileged role** — switches the role into the pure realm-admin mode. + Enabling it clears and disables the Application link and catalog + permissions. It grants `realm:admin` in this realm only and is reserved + for the System Admin role. **Permissions** @@ -128,7 +131,7 @@ all from the `modgud` catalog, all on one role. ## Cloning a role -To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: the linked Application, the selected permission subset and the realm-admin flag are copied; only the **Name** is blank. Give the copy a new name, adjust the permission selection, and create. +To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: for an application role, the linked Application and selected permission subset are copied; for a realm-admin role, only the pure realm-admin mode is copied. The **Name** is blank. Give the copy a new name, adjust the selection, and create. ## Cross-app roles (special case) @@ -146,7 +149,7 @@ A role becomes a bypass role through either of two mechanisms: | Mechanism | Effect | | --- | --- | -| **Privileged role** checkbox set | realm-wide bypass (`realm:admin`) — works in every app, ignores the Application link | +| Pure **Privileged role** | current-realm-wide bypass (`realm:admin`) — works in every app in this realm and has no Application link | | A catalog entry with action `admin` checked (e.g. `user:admin`) | resource-wide bypass — every action on that resource, within the role's linked Application | There's no app-wide bypass in between — a role is either realm-wide or scoped down to individual resources. diff --git a/docs/concepts/groups-and-authorization.md b/docs/concepts/groups-and-authorization.md index 139d1673..b3f45ff7 100644 --- a/docs/concepts/groups-and-authorization.md +++ b/docs/concepts/groups-and-authorization.md @@ -72,7 +72,8 @@ bypass-pre-expansion, per-RS subset narrowing) see the canonical The IAM hosts an arbitrary number of consuming apps in one realm; each is identified by a slug (`modgud`, `acme`, `billing`, …). -PermissionRoles bind to one app (via `AppId`); groups carry an +Application PermissionRoles bind to one app (via `AppId`); a pure +`realm:admin` role is the explicit exception. Groups carry an activation list (via `BoundTo`). A group's `BoundTo` field is the **activation switch**: it lists the diff --git a/docs/getting-started/features.md b/docs/getting-started/features.md index f72b9649..e6c7f7e1 100644 --- a/docs/getting-started/features.md +++ b/docs/getting-started/features.md @@ -36,7 +36,7 @@ A point-by-point list of what Modgud delivers out of the box. ### Multi-app permission model - **Apps** as first-class organisational containers within a realm - **Resources** declared per app -- **Roles** bound to one app, holding permissions on its resources +- **Application roles** bound to one app, holding permissions on its resources; pure `realm:admin` roles are the explicit realm-local exception - **Groups** with `BoundTo` activation switch — wildcard `*`, specific apps, or dormant - Permission strings shaped `:` (two segments; app context implicit from the catalog container) with two bypass tiers (`realm:admin`, `:admin`) - Apps also carry their own soft configuration facet — origin, branding, and login posture — while still sharing the realm's user pool and a single `sub` per user diff --git a/docs/index.md b/docs/index.md index b5b92745..9eb74e1d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ features: details: Every realm gets its own PostgreSQL database via Marten's master-table tenancy. Domain-based routing maps Host headers to tenants — no tenant_id columns, no cross-realm leaks possible. - icon: '' title: Multi-app permission model - details: Apps are first-class. Permissions are two-segment `:` strings (e.g. `todo:write`) scoped to an app via the role→App relationship, groups carry an activation list (BoundTo), roles bind to one app, and the resolver answers per-app permission queries in-memory. + details: Apps are first-class. Permissions are two-segment `:` strings (e.g. `todo:write`) scoped through the role→App relationship. Application roles bind to one app; a pure `realm:admin` role is the explicit realm-local exception. Groups carry an activation list (BoundTo), and the resolver answers per-app permission queries in-memory. - icon: '' title: Per-app resource_access claim details: Tokens carry resource_access keyed by app slug, shaped like Keycloak's nested claim for familiarity. The ASP.NET Core resource-server handlers project the selected audience block into native role and permission claims. diff --git a/docs/integrate/saas-walkthrough.md b/docs/integrate/saas-walkthrough.md index 5871515d..688961ec 100644 --- a/docs/integrate/saas-walkthrough.md +++ b/docs/integrate/saas-walkthrough.md @@ -161,7 +161,8 @@ usually want more nuanced roles. | **App** | `acme` | | Permissions | `todo:read`, `todo:write` | -Roles bind to one App via `AppId`; the `PermissionIds` reference +Application roles bind to one App via `AppId`; a pure `realm:admin` +role is the explicit realm-local exception. The `PermissionIds` reference specific catalog entries of that App. The same string `todo:read` in a different App's catalog is a different permission. diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs index a7883f80..ba1b9e4a 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/RolesEndpointsRobustnessTests.cs @@ -55,4 +55,35 @@ public async Task Create_realm_admin_role_without_PermissionIds_succeeds() var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Contains("Stage6 Realm Admin Role", body); } + + [Fact] + public async Task Create_realm_admin_role_with_AppId_is_rejected() + { + var res = await Client.PostAsJsonAsync("/api/role", new + { + Name = "Mixed Realm Admin Role", + AppId = Guid.NewGuid().ToString(), + IsRealmAdmin = true, + PermissionIds = Array.Empty(), + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Role.RealmAdminMustBeUnscoped", body); + } + + [Fact] + public async Task Create_realm_admin_role_with_App_permissions_is_rejected() + { + var res = await Client.PostAsJsonAsync("/api/role", new + { + Name = "Mixed Realm Admin Grants", + IsRealmAdmin = true, + PermissionIds = new[] { Guid.NewGuid().ToString() }, + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + var body = await res.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("Role.RealmAdminMustBeUnscoped", body); + } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs index b458d524..cb0aa97e 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifest.cs @@ -203,13 +203,13 @@ public sealed record RealmManifestRole [Description("Optional description.")] public string? Description { get; init; } - [Description("App slug whose catalog Permissions resolve into. Omit for a pure realm-admin role.")] + [Description("App slug whose catalog Permissions resolve into. Required for an App role; forbidden for a realm-admin role.")] public string? App { get; init; } - [Description("If true, this role confers realm:admin — the realm-wide bypass (full administration). A realm-admin role needs no App/Permissions. Provisioning is trusted, so this is allowed from the manifest.")] + [Description("If true, this role confers realm:admin across every App in this realm. App and Permissions must both be omitted.")] public bool IsRealmAdmin { get; init; } - [Description("Permissions from the linked app's catalog this role grants (requires App).")] + [Description("Permissions from the linked App's catalog this role grants. Requires App and is forbidden for a realm-admin role.")] public List Permissions { get; init; } = []; public string ResolveKey() => Key ?? Name; diff --git a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs index 6cccaa7d..55608872 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Provisioning/RealmManifestExporter.cs @@ -132,10 +132,16 @@ public async Task> ExportRealmAsync(string slug, Cancella { Name = r.Name, Description = r.Description, - App = r.AppId is { } aid && appSlugById.TryGetValue(aid, out var slugOf) ? slugOf : null, + App = !r.IsRealmAdmin + && r.AppId is { } aid + && appSlugById.TryGetValue(aid, out var slugOf) + ? slugOf + : null, IsRealmAdmin = r.IsRealmAdmin, - Permissions = r.PermissionIds - .Where(permKeyById.ContainsKey).Select(id => permKeyById[id]).ToList(), + Permissions = r.IsRealmAdmin + ? [] + : r.PermissionIds + .Where(permKeyById.ContainsKey).Select(id => permKeyById[id]).ToList(), }).ToList(); // ── Users (raw Person for the human list + ApplicationUser for EmailConfirmed) ─ diff --git a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs index e78d9f4e..de440e66 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RoleAdminService.cs @@ -94,8 +94,9 @@ public async Task> DeleteRoleAsync(Guid id, CancellationToken c /// /// Validates a payload into a (Id minted here): AppId /// resolves to an existing App, every PermissionId resolves to that App's catalog, - /// PermissionIds require an App link, and a role must grant something (App link or - /// IsRealmAdmin). + /// and PermissionIds require an App link. Roles have exactly one scope: ordinary + /// roles are App-bound, while realm-admin roles are deliberately unscoped and may + /// carry neither an App link nor catalog permissions. /// public async Task> BuildRoleAsync(RolePayload dto, CancellationToken ct = default) { @@ -104,6 +105,14 @@ public async Task> BuildRoleAsync(RolePayload dto, Cance var permissionIdsInput = dto.PermissionIds ?? []; + if (dto.IsRealmAdmin + && (!string.IsNullOrWhiteSpace(dto.AppId) || permissionIdsInput.Count > 0)) + { + return Error.Validation( + "Role.RealmAdminMustBeUnscoped", + "A realm-admin role cannot be linked to an App or carry App permissions."); + } + Guid? appId = null; App? linkedApp = null; if (!string.IsNullOrEmpty(dto.AppId)) diff --git a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs index e332b191..818068ca 100644 --- a/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Roles/RolesEndpoints.cs @@ -9,9 +9,9 @@ namespace Modgud.Api.Features.Roles; /// /// Create/Update payload for a . -/// is the (ShortGuid) FK into the role's App; null = the role is a pure -/// realm-admin role and must therefore set to -/// true and leave empty. Each +/// is the (ShortGuid) FK into an ordinary role's App. A realm-admin role +/// must instead leave null and +/// empty. Each /// entry is an AppPermission.Id /// (ShortGuid) FK into App.Permissions of the linked App; the /// admin endpoint validates them at write-time. diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs index e16a2740..f624527b 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCommands.cs @@ -296,8 +296,9 @@ await ctx.Services.GetRequiredService().RecordRequiredAsync(n /// /// Rebuilds all Marten projections from event 0. Mirrors the admin rebuild /// endpoint but runs without auth — needed when a schema change leaves -/// mt_doc_principal empty so no user can claim app:admin until the -/// principal projection is replayed. +/// mt_doc_principal empty so no user's App-scoped grants or +/// realm:admin bypass can resolve until the principal projection is +/// replayed. /// internal sealed class RebuildProjectionsCommand : IRecoveryCommand { diff --git a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs index 91067770..735d26d7 100644 --- a/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs +++ b/src/dotnet/Modgud.Authentication/Domain/LoginProviders/LoginProvider.cs @@ -118,8 +118,9 @@ public class LoginProvider /// /// Federation v1 (decision G): when true, this provider's claims may - /// drive app:admin-and-below group membership at login (gated further - /// by per-group ). Mirror of + /// drive ordinary App-scoped and <resource>:admin group + /// membership at login (gated further by per-group + /// ). Mirror of /// . realm:admin is never externally /// drivable regardless of this flag. Default false. /// diff --git a/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs b/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs index f692bfc3..bde83e89 100644 --- a/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs +++ b/src/dotnet/Modgud.Authorization/Roles/PermissionRole.cs @@ -11,16 +11,15 @@ namespace Modgud.Authorization.Roles; /// of the role's . Survive resource/action renames in /// the catalog. Roles can grant any subset of their App's catalog, /// including multiple resources within the same App. -/// — when true, the role grants -/// realm:admin regardless of . Reserved for the -/// System Admin role; bypasses every permission check across every realm. +/// — when true, the role has no +/// and grants realm:admin. Reserved for the +/// System Admin role; bypasses every permission check across every App in +/// the current realm, never across realm boundaries. /// /// -/// is nullable so that a pure-realm-admin role -/// ( = true, no catalog grants) can be modelled -/// without the operator having to pick an arbitrary App. When -/// is null, must be empty — -/// nothing to FK into. +/// These modes are mutually exclusive. An ordinary role has an +/// and optional grants from that App's catalog. A +/// realm-admin role has no App link and no catalog grants. /// public class PermissionRole { @@ -29,24 +28,24 @@ public class PermissionRole public string? Description { get; set; } /// - /// FK to App.Id. Null only for pure-realm-admin roles. When set, - /// the role's grants are interpreted within that App's catalog. + /// FK to App.Id. Required for ordinary roles and null for + /// realm-admin roles. When set, the role's grants are interpreted within + /// that App's catalog. /// public Guid? AppId { get; set; } /// - /// When true, the role grants realm:admin — the realm-wide bypass - /// recognised by Modgud.Permissions.PermissionEvaluator. Lives - /// outside any App catalog (see permission-modell.md §3 "Sonderfall - /// realm:admin"). + /// When true, the role grants realm:admin — the current-realm-wide + /// bypass recognised by Modgud.Permissions.PermissionEvaluator. + /// Lives outside every App catalog and requires and + /// to be empty. /// public bool IsRealmAdmin { get; set; } /// /// Subset of the role's App catalog this role grants. Each entry is an /// AppPermission.Id in 's App. Empty when the - /// role grants nothing through the catalog (only valid alongside - /// ). + /// role is an ordinary App role. Always empty for a realm-admin role. /// public List PermissionIds { get; set; } = new(); diff --git a/src/frontend-vue/src/views/admin/AdminView.vue b/src/frontend-vue/src/views/admin/AdminView.vue index 23c9cf57..728d2f19 100644 --- a/src/frontend-vue/src/views/admin/AdminView.vue +++ b/src/frontend-vue/src/views/admin/AdminView.vue @@ -24,8 +24,9 @@ interface NavItemDef { to: string /** * Resource permissions that grant visibility. Matches if the user holds - * any of these. `app:admin` is a global bypass and applied implicitly by - * `authStore.hasPermission`. + * any of these. `realm:admin` is the current-realm bypass and + * `:admin` is the resource-wide bypass; both are applied + * implicitly by `authStore.hasPermission`. */ requirePermissions: string[] /** diff --git a/src/frontend-vue/src/views/admin/role/RoleDetails.vue b/src/frontend-vue/src/views/admin/role/RoleDetails.vue index 5466185a..df338125 100644 --- a/src/frontend-vue/src/views/admin/role/RoleDetails.vue +++ b/src/frontend-vue/src/views/admin/role/RoleDetails.vue @@ -69,23 +69,25 @@ const modalTitle = computed(() => { const footerButton = computed(() => ({ visible: true, text: isCreate.value ? t('common.create', {}, 'Create') : t('common.save', {}, 'Save'), - disabled: !form.value.Name.trim() || loading.value, + disabled: !form.value.Name.trim() + || (!form.value.IsRealmAdmin && !form.value.AppId) + || loading.value, onClick: save, })) onMounted(async () => { applicationsStore.initialize() if (isCreate.value) { - // Clone: prefill from the staged source with the Name blanked. The App-link - // + its catalog subset clone 1:1. + // Clone: prefill from the staged source with the Name blanked. Realm-admin + // roles are normalized to their deliberately App-less shape. const clone = consume(ROLE_CLONE.entity) if (clone) { form.value = { Name: clone.Name ?? '', Description: clone.Description || '', - AppId: clone.AppId ?? '', + AppId: clone.IsRealmAdmin ? '' : (clone.AppId ?? ''), IsRealmAdmin: clone.IsRealmAdmin, - PermissionIds: new Set(clone.PermissionIds ?? []), + PermissionIds: new Set(clone.IsRealmAdmin ? [] : (clone.PermissionIds ?? [])), } } return @@ -98,9 +100,9 @@ onMounted(async () => { form.value = { Name: role.Name, Description: role.Description || '', - AppId: role.AppId ?? '', + AppId: role.IsRealmAdmin ? '' : (role.AppId ?? ''), IsRealmAdmin: role.IsRealmAdmin, - PermissionIds: new Set(role.PermissionIds ?? []), + PermissionIds: new Set(role.IsRealmAdmin ? [] : (role.PermissionIds ?? [])), } } } finally { @@ -120,6 +122,14 @@ function onAppIdChange() { form.value.PermissionIds = new Set() } +function onRealmAdminChange(value: boolean) { + form.value.IsRealmAdmin = value + if (value) { + form.value.AppId = '' + form.value.PermissionIds = new Set() + } +} + async function save() { if (!form.value.Name.trim()) return loading.value = true @@ -127,9 +137,11 @@ async function save() { const dto = { Name: form.value.Name, Description: form.value.Description || null, - AppId: form.value.AppId || null, + AppId: form.value.IsRealmAdmin ? null : (form.value.AppId || null), IsRealmAdmin: form.value.IsRealmAdmin, - PermissionIds: form.value.AppId ? Array.from(form.value.PermissionIds) : [], + PermissionIds: form.value.IsRealmAdmin + ? [] + : (form.value.AppId ? Array.from(form.value.PermissionIds) : []), } if (isCreate.value) { await roleStore.createRole(dto) @@ -153,7 +165,7 @@ async function save() { {{ t('admin.roleDetails.tabs.permissions', {}, 'Permissions') }} -
@@ -170,12 +182,15 @@ async function save() {

- {{ form.AppId + {{ form.IsRealmAdmin + ? t('admin.roleDetails.app.realmAdminHint', {}, 'Realm-admin roles are deliberately not linked to an application.') + : form.AppId ? t('admin.roleDetails.app.linkedHint', {}, 'Role grants the selected permissions of this application.') - : t('admin.roleDetails.app.noneHint', {}, 'No application link — only the realm-admin flag below grants anything. Reserved for the System Admin role.') }} + : t('admin.roleDetails.app.noneHint', {}, 'Choose exactly one application, or enable the realm-admin role below.') }}

@@ -185,18 +200,19 @@ async function save() {
- +

{{ t('admin.roleDetails.section.danger', {}, 'Permissions — caution') }}

@@ -209,11 +225,13 @@ async function save() { because they're admin-functionally the same: nothing to pick. -->

- {{ t('admin.roleDetails.permissions.noApp', {}, 'This role isn\'t bound to any application — there\'s nothing to grant. Choose an app in the General tab, then its catalog will appear here.') }} + {{ form.IsRealmAdmin + ? t('admin.roleDetails.permissions.realmAdmin', {}, 'A realm-admin role needs no catalog entries; it bypasses every App permission inside this realm.') + : t('admin.roleDetails.permissions.noApp', {}, 'Choose an app in the General tab, then its catalog will appear here.') }}