Add Gitea SSO, fix security audit findings, harden production defaults
Gitea SSO: cookie-based single sign-on via nginx auth_request — sets cml_session cookie on login/refresh, validates via /api/auth/gitea-sso-validate, injects X-WEBAUTH-USER header for reverse proxy auth. Dedicated GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT env vars isolate secret rotation. Security fixes from March 30 audit: IDOR on ticketed events (requireEventOwnership middleware), IDOR on action items (admin/assignee/creator check), path traversal on photos (resolve-based validation), CSV upload size limit (5MB), shared calendar email exposure removed. Gitea provisioner: auto-sync docs repo collaborator access based on role (CONTENT_ROLES get write, SUPER_ADMIN gets admin). Gitea client extended with collaborator management API methods. Production hardening: NODE_ENV defaults to production in docker-compose.prod.yml, Grafana anonymous auth disabled, install.sh branch ref updated to main. Admin UI: moved docs reset from toolbar to MkDocs Settings danger zone, improved collab Ctrl+S to explicitly save + cache-bust preview. MkDocs site rebuild with updated repo data, upgrade screenshots, and content. Bunker Admin
This commit is contained in:
135
api/package-lock.json
generated
135
api/package-lock.json
generated
@@ -1656,14 +1656,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@js-temporal/polyfill": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
|
||||
@@ -1901,6 +1893,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
@@ -2102,9 +2095,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2216,14 +2209,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
@@ -2260,14 +2250,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
@@ -2548,6 +2538,7 @@
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
@@ -2700,15 +2691,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.9",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
|
||||
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
|
||||
"version": "0.31.10",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||
"esbuild": "^0.25.4",
|
||||
"esbuild-register": "^3.5.0"
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"bin": {
|
||||
"drizzle-kit": "bin.cjs"
|
||||
@@ -3426,41 +3417,6 @@
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-register": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": ">=0.12 <1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-register/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-register/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -3623,9 +3579,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
|
||||
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
|
||||
"version": "5.8.4",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3646,7 +3602,7 @@
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^10.1.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
@@ -4066,20 +4022,6 @@
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -4407,14 +4349,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -4542,9 +4484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -4705,9 +4647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
@@ -5000,9 +4942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -5848,10 +5790,9 @@
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ const envSchema = z.object({
|
||||
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
||||
|
||||
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
|
||||
GITEA_SSO_SECRET: z.string().default(''),
|
||||
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
|
||||
SERVICE_PASSWORD_SALT: z.string().default(''),
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UserRole, UserStatus } from '@prisma/client';
|
||||
import { authService } from './auth.service';
|
||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||
@@ -22,6 +23,9 @@ const router = Router();
|
||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
|
||||
const SESSION_COOKIE_NAME = 'cml_session';
|
||||
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
|
||||
|
||||
/** Set the refresh token as an httpOnly cookie.
|
||||
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
|
||||
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
|
||||
@@ -45,6 +49,53 @@ function clearRefreshCookie(req: Request, res: Response) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Cookie options for the SSO session cookie (domain-wide for Gitea reverse proxy auth) */
|
||||
function sessionCookieOptions(req: Request) {
|
||||
const isSecure = req.secure;
|
||||
const domain = env.DOMAIN;
|
||||
// Use domain-wide cookie for production (subdomains); omit for localhost dev
|
||||
const hasDomain = domain && !domain.includes('localhost') && !domain.match(/^\d/);
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
...(hasDomain ? { domain: `.${domain}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set the SSO session cookie for Gitea reverse proxy auth (fire-and-forget).
|
||||
* Only sets the cookie if the user has a provisioned Gitea account. */
|
||||
async function setSessionCookie(req: Request, res: Response, userId: string) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { permissions: true },
|
||||
});
|
||||
const permissions = (user?.permissions as Record<string, unknown>) || {};
|
||||
const giteaUser = permissions._giteaUsername as string | undefined;
|
||||
if (!giteaUser) return; // Not provisioned — skip
|
||||
|
||||
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
|
||||
const token = jwt.sign(
|
||||
{ sub: userId, giteaUser },
|
||||
ssoSecret,
|
||||
{ algorithm: 'HS256', expiresIn: '15m' },
|
||||
);
|
||||
res.cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(req));
|
||||
} catch (err) {
|
||||
logger.debug('Failed to set SSO session cookie:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the SSO session cookie */
|
||||
function clearSessionCookie(req: Request, res: Response) {
|
||||
const opts = sessionCookieOptions(req);
|
||||
delete (opts as Record<string, unknown>).maxAge;
|
||||
res.clearCookie(SESSION_COOKIE_NAME, opts);
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
@@ -55,6 +106,8 @@ router.post(
|
||||
const result = await authService.login(req.body.email, req.body.password);
|
||||
// Set refresh token as httpOnly cookie (not in response body)
|
||||
setRefreshCookie(req, res, result.refreshToken);
|
||||
// Set SSO session cookie for Gitea reverse proxy auth
|
||||
await setSessionCookie(req, res, result.user.id);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
@@ -281,6 +334,10 @@ router.post(
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
// Set new refresh token as httpOnly cookie
|
||||
setRefreshCookie(req, res, result.refreshToken);
|
||||
// Renew SSO session cookie for Gitea reverse proxy auth
|
||||
if (result.user?.id) {
|
||||
await setSessionCookie(req, res, result.user.id);
|
||||
}
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
@@ -302,9 +359,11 @@ router.post(
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
clearRefreshCookie(req, res);
|
||||
clearSessionCookie(req, res);
|
||||
res.json({ message: 'Logged out' });
|
||||
} catch (err) {
|
||||
clearRefreshCookie(req, res);
|
||||
clearSessionCookie(req, res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
50
api/src/modules/auth/gitea-sso.routes.ts
Normal file
50
api/src/modules/auth/gitea-sso.routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface SsoPayload {
|
||||
sub: string;
|
||||
giteaUser: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/gitea-sso-validate
|
||||
*
|
||||
* Called by nginx auth_request to validate the cml_session cookie.
|
||||
* Always returns 200 — never blocks requests.
|
||||
* Sets X-Gitea-User header when the session is valid and the user
|
||||
* has a provisioned Gitea account. Empty header = no SSO (Gitea
|
||||
* shows its own login page).
|
||||
*/
|
||||
router.get('/gitea-sso-validate', (req: Request, res: Response) => {
|
||||
const token = req.cookies?.cml_session;
|
||||
|
||||
if (!token) {
|
||||
res.setHeader('X-Gitea-User', '');
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
|
||||
const payload = jwt.verify(token, ssoSecret, {
|
||||
algorithms: ['HS256'],
|
||||
}) as SsoPayload;
|
||||
|
||||
if (payload.giteaUser) {
|
||||
res.setHeader('X-Gitea-User', payload.giteaUser);
|
||||
} else {
|
||||
res.setHeader('X-Gitea-User', '');
|
||||
}
|
||||
} catch {
|
||||
// Expired or invalid JWT — no SSO, graceful fallback
|
||||
res.setHeader('X-Gitea-User', '');
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
export { router as giteaSsoRouter };
|
||||
@@ -925,7 +925,7 @@ export const sharedCalendarService = {
|
||||
include: {
|
||||
members: {
|
||||
where: { status: SharedViewMemberStatus.ACCEPTED },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -972,7 +972,7 @@ export const sharedCalendarService = {
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
const memberName = member.user.name || member.user.email;
|
||||
const memberName = member.user.name || 'Member';
|
||||
for (const item of items) {
|
||||
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
|
||||
allItems.push({
|
||||
@@ -999,7 +999,7 @@ export const sharedCalendarService = {
|
||||
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
|
||||
for (const sysLayer of sysLayers) {
|
||||
const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end);
|
||||
const memberName = member.user.name || member.user.email;
|
||||
const memberName = member.user.name || 'Member';
|
||||
for (const si of sysItems) {
|
||||
allItems.push({
|
||||
...si,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@@ -181,19 +182,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath || filePath.includes('..')) {
|
||||
if (!filePath) {
|
||||
return reply.code(404).send({ message: 'Image variant not found' });
|
||||
}
|
||||
const PHOTOS_BASE = '/media/local/photos';
|
||||
const resolvedPath = resolve(filePath);
|
||||
if (!resolvedPath.startsWith(resolve(PHOTOS_BASE) + '/')) {
|
||||
logger.warn(`Photo path traversal attempt blocked: ${filePath}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
await access(resolvedPath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Image file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||
return reply.send(createReadStream(filePath));
|
||||
return reply.send(createReadStream(resolvedPath));
|
||||
}
|
||||
);
|
||||
|
||||
@@ -208,19 +215,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
||||
select: { thumbnailPath: true },
|
||||
});
|
||||
|
||||
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
|
||||
if (!photo?.thumbnailPath) {
|
||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||
}
|
||||
const PHOTOS_BASE = '/media/local/photos';
|
||||
const resolvedThumb = resolve(photo.thumbnailPath);
|
||||
if (!resolvedThumb.startsWith(resolve(PHOTOS_BASE) + '/')) {
|
||||
logger.warn(`Thumbnail path traversal attempt blocked: ${photo.thumbnailPath}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(photo.thumbnailPath);
|
||||
await access(resolvedThumb);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'image/jpeg');
|
||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||
return reply.send(createReadStream(photo.thumbnailPath));
|
||||
return reply.send(createReadStream(resolvedThumb));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
import { EVENTS_ROLES, hasAnyRole } from '../../utils/roles';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,6 +45,14 @@ router.get('/:id', authenticate, async (req: Request, res: Response, next: NextF
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const item = await actionItemsService.findById(id);
|
||||
|
||||
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
|
||||
const isAssignee = item.assigneeUserId === req.user!.id;
|
||||
const isCreator = item.createdByUserId === req.user!.id;
|
||||
if (!isAdmin && !isAssignee && !isCreator) {
|
||||
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -56,10 +65,19 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update action item (authenticate only - assignees can update their own)
|
||||
// Update action item — admins, assignees, or creators can update
|
||||
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const existing = await actionItemsService.findById(id);
|
||||
|
||||
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
|
||||
const isAssignee = existing.assigneeUserId === req.user!.id;
|
||||
const isCreator = existing.createdByUserId === req.user!.id;
|
||||
if (!isAdmin && !isAssignee && !isCreator) {
|
||||
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
|
||||
}
|
||||
|
||||
const item = await actionItemsService.update(id, req.body);
|
||||
res.json(item);
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -171,6 +171,11 @@ router.post('/:id/import-csv', async (req, res, next) => {
|
||||
res.status(400).json({ error: 'CSV text is required in the "csv" field' });
|
||||
return;
|
||||
}
|
||||
const MAX_CSV_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
if (csv.length > MAX_CSV_SIZE) {
|
||||
res.status(400).json({ error: { message: 'CSV too large (max 5MB)', code: 'CSV_TOO_LARGE' } });
|
||||
return;
|
||||
}
|
||||
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -39,6 +39,30 @@ async function requireEventPermission(req: Request, _res: Response, next: NextFu
|
||||
return next({ status: 403, message: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
/** Middleware: for :id routes, verify non-admin users own the event */
|
||||
async function requireEventOwnership(req: Request, res: Response, next: NextFunction) {
|
||||
const eventId = req.params.id as string;
|
||||
if (!eventId) return next();
|
||||
|
||||
const userRoles = req.user!.roles || [req.user!.role];
|
||||
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
|
||||
if (isAdmin) return next();
|
||||
|
||||
const event = await prisma.ticketedEvent.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { createdByUserId: true },
|
||||
});
|
||||
if (!event) {
|
||||
res.status(404).json({ error: { message: 'Event not found', code: 'NOT_FOUND' } });
|
||||
return;
|
||||
}
|
||||
if (event.createdByUserId !== req.user!.id) {
|
||||
res.status(403).json({ error: { message: 'Forbidden', code: 'FORBIDDEN' } });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// All routes require auth + event permission
|
||||
router.use(authenticate, requireEventPermission);
|
||||
|
||||
@@ -73,7 +97,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
|
||||
});
|
||||
|
||||
// GET /:id — event detail
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const event = await ticketedEventsService.findById(req.params.id as string);
|
||||
res.json(event);
|
||||
@@ -144,7 +168,7 @@ router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request,
|
||||
// --- Meeting ---
|
||||
|
||||
// POST /:id/meeting-token — generate moderator JWT for Jitsi
|
||||
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/meeting-token', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
@@ -159,7 +183,7 @@ router.post('/:id/meeting-token', async (req: Request, res: Response, next: Next
|
||||
// --- Tiers ---
|
||||
|
||||
// POST /:id/tiers
|
||||
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
|
||||
res.status(201).json(tier);
|
||||
@@ -167,7 +191,7 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
|
||||
});
|
||||
|
||||
// PUT /:id/tiers/:tierId
|
||||
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const tier = await ticketedEventsService.updateTier(
|
||||
req.params.tierId as string,
|
||||
@@ -179,7 +203,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
|
||||
});
|
||||
|
||||
// DELETE /:id/tiers/:tierId
|
||||
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
|
||||
res.json({ success: true });
|
||||
@@ -189,7 +213,7 @@ router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: Ne
|
||||
// --- Tickets ---
|
||||
|
||||
// GET /:id/tickets
|
||||
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
@@ -201,7 +225,7 @@ router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunctio
|
||||
});
|
||||
|
||||
// GET /:id/checkins
|
||||
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
@@ -211,7 +235,7 @@ router.get('/:id/checkins', async (req: Request, res: Response, next: NextFuncti
|
||||
});
|
||||
|
||||
// GET /:id/stats
|
||||
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
|
||||
res.json(stats);
|
||||
@@ -219,7 +243,7 @@ router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction)
|
||||
});
|
||||
|
||||
// POST /:id/resend-ticket/:ticketId
|
||||
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: req.params.ticketId as string },
|
||||
@@ -273,7 +297,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
|
||||
});
|
||||
|
||||
// POST /:id/tickets/:ticketId/cancel
|
||||
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.post('/:id/tickets/:ticketId/cancel', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await ticketsService.cancelTicket(req.params.ticketId as string);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -599,6 +599,51 @@ class GiteaClient {
|
||||
{ name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Repository Collaborator Management ---
|
||||
|
||||
/**
|
||||
* Add a collaborator to a repository with the specified permission level.
|
||||
* @param permission - "read", "write", or "admin"
|
||||
*/
|
||||
async addCollaborator(
|
||||
owner: string,
|
||||
repo: string,
|
||||
username: string,
|
||||
permission: 'read' | 'write' | 'admin' = 'write',
|
||||
): Promise<void> {
|
||||
await this.request(
|
||||
'PUT',
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||
{ permission },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a collaborator from a repository.
|
||||
*/
|
||||
async removeCollaborator(owner: string, repo: string, username: string): Promise<void> {
|
||||
await this.request(
|
||||
'DELETE',
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a collaborator on a repository.
|
||||
* Returns true if they are, false otherwise.
|
||||
*/
|
||||
async isCollaborator(owner: string, repo: string, username: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(
|
||||
'GET',
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const giteaClient = new GiteaClient();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { giteaClient } from '../gitea.client';
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
|
||||
|
||||
const ROLE_MAP: Record<string, string[]> = {
|
||||
@@ -14,9 +15,13 @@ const ROLE_MAP: Record<string, string[]> = {
|
||||
TEMP: [],
|
||||
};
|
||||
|
||||
/** The private docs repo name created by gitea-setup */
|
||||
const DOCS_REPO_NAME = 'changemaker.lite';
|
||||
|
||||
/** Deterministic password — never exposed to users */
|
||||
function generateGiteaPassword(userId: string): string {
|
||||
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
||||
return createHmac('sha256', salt)
|
||||
.update(`gitea:${userId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -94,6 +99,9 @@ class GiteaProvisioner implements ServiceProvisioner {
|
||||
},
|
||||
});
|
||||
|
||||
// Grant docs repo access based on role
|
||||
await this.syncDocsRepoAccess(giteaUser.login, user.role);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
serviceUserId: String(giteaUser.id),
|
||||
@@ -121,6 +129,9 @@ class GiteaProvisioner implements ServiceProvisioner {
|
||||
admin: isAdmin,
|
||||
active: user.status === 'ACTIVE',
|
||||
});
|
||||
|
||||
// Re-evaluate docs repo access based on current role
|
||||
await this.syncDocsRepoAccess(username, user.role);
|
||||
}
|
||||
|
||||
async deactivate(serviceUserId: string): Promise<void> {
|
||||
@@ -140,13 +151,57 @@ class GiteaProvisioner implements ServiceProvisioner {
|
||||
}
|
||||
|
||||
await giteaClient.adminUpdateUser(username, { active: false });
|
||||
|
||||
// Remove docs repo collaborator access
|
||||
try {
|
||||
const config = await giteaClient.getConfig();
|
||||
if (config.repoOwner) {
|
||||
await giteaClient.removeCollaborator(config.repoOwner, DOCS_REPO_NAME, username);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — user may not have been a collaborator
|
||||
}
|
||||
|
||||
logger.info(`Gitea provisioner: deactivated user ${username}`);
|
||||
}
|
||||
|
||||
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
|
||||
// Gitea SSO via API tokens could be implemented here if needed for iframe embedding
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the user's collaborator status on the private docs repo matches
|
||||
* their role. CONTENT_ROLES (SUPER_ADMIN, CONTENT_ADMIN) get write access;
|
||||
* SUPER_ADMIN users already have admin access via the Gitea admin flag,
|
||||
* but we also add them as explicit collaborators for consistency.
|
||||
* Users without CONTENT_ROLES are removed as collaborators.
|
||||
*/
|
||||
private async syncDocsRepoAccess(username: string, role: string): Promise<void> {
|
||||
try {
|
||||
const config = await giteaClient.getConfig();
|
||||
const repoOwner = config.repoOwner;
|
||||
if (!repoOwner) return; // Setup not complete — no repo owner yet
|
||||
|
||||
const hasDocsAccess = CONTENT_ROLES.includes(role as UserRole);
|
||||
|
||||
if (hasDocsAccess) {
|
||||
// SUPER_ADMIN → admin, CONTENT_ADMIN → write
|
||||
const permission = role === 'SUPER_ADMIN' ? 'admin' : 'write';
|
||||
await giteaClient.addCollaborator(repoOwner, DOCS_REPO_NAME, username, permission);
|
||||
logger.debug(`Gitea provisioner: granted ${permission} access to ${repoOwner}/${DOCS_REPO_NAME} for ${username}`);
|
||||
} else {
|
||||
// Remove access if user no longer has CONTENT_ROLES
|
||||
const isCollab = await giteaClient.isCollaborator(repoOwner, DOCS_REPO_NAME, username);
|
||||
if (isCollab) {
|
||||
await giteaClient.removeCollaborator(repoOwner, DOCS_REPO_NAME, username);
|
||||
logger.debug(`Gitea provisioner: removed ${username} from ${repoOwner}/${DOCS_REPO_NAME}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — don't block provisioning if repo access sync fails
|
||||
logger.warn(`Gitea provisioner: docs repo access sync failed for ${username}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const giteaProvisioner = new GiteaProvisioner();
|
||||
|
||||
@@ -16,7 +16,8 @@ const ROLE_MAP: Record<string, string[]> = {
|
||||
|
||||
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
||||
function generateRCPassword(userId: string): string {
|
||||
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
||||
return createHmac('sha256', salt)
|
||||
.update(`rc:${userId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user