This commit is contained in:
jarek committed 2026-04-18 08:34:20 +02:00
1 parent c210ef0a8e
commit afee09866d
55 files changed
+7767 -131

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
import { getUser } from '$lib/server/db';
import { audit } from '$lib/server/audit';
import { getRequestContext } from '$lib/server/request-context';
// Password confirmation rate limiting (per userId)
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
const pwCooldowns = new Map<number, number>();
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
const PW_FAIL_MAX = 5; // max failures per window
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
const MAX_TOKENS_PER_USER = 25;
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [id, until] of pwCooldowns) {
if (now > until) pwCooldowns.delete(id);
}
for (const [id, entry] of pwFailCounts) {
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
}
}, PW_COOLDOWN).unref?.();
function isPwRateLimited(userId: number): boolean {
const until = pwCooldowns.get(userId);
if (!until) return false;
if (Date.now() > until) {
pwCooldowns.delete(userId);
return false;
}
return true;
}
function recordPwFailure(userId: number): void {
const now = Date.now();
const entry = pwFailCounts.get(userId);
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
pwFailCounts.set(userId, { count: 1, firstFail: now });
return;
}
entry.count++;
if (entry.count >= PW_FAIL_MAX) {
pwCooldowns.set(userId, now + PW_COOLDOWN);
pwFailCounts.delete(userId);
}
}
/**
* GET /api/auth/tokens - List the current user's API tokens
*/
export const GET: RequestHandler = async ({ cookies }) => {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokens = await listUserTokens(auth.user.id);
return json(tokens);
};
/**
* POST /api/auth/tokens - Create a new API token
*/
export const POST: RequestHandler = async (event) => {
const { cookies, request } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
// Token creation requires a cookie session — a Bearer token cannot create new tokens
const reqCtx = getRequestContext();
if (reqCtx?.authMethod === 'bearer') {
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
}
let body: any;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid request body' }, { status: 400 });
}
const { name, expiresAt, password } = body;
// Local users must confirm their password to create tokens
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
if (auth.user.provider === 'local') {
if (isPwRateLimited(auth.user.id)) {
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
}
if (!password || typeof password !== 'string') {
return json({ error: 'Password is required to create an API token' }, { status: 400 });
}
const dbUser = await getUser(auth.user.id);
if (!dbUser) {
return json({ error: 'User not found' }, { status: 404 });
}
const valid = await verifyPassword(password, dbUser.passwordHash);
if (!valid) {
recordPwFailure(auth.user.id);
return json({ error: 'Invalid password' }, { status: 403 });
}
}
// L2: Per-user token count limit
const existingTokens = await listUserTokens(auth.user.id);
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
}
// Validate name
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return json({ error: 'Token name is required' }, { status: 400 });
}
if (name.length > 255) {
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
}
// Validate expiration
if (expiresAt) {
const expiryDate = new Date(expiresAt);
if (isNaN(expiryDate.getTime())) {
return json({ error: 'Invalid expiration date' }, { status: 400 });
}
if (expiryDate <= new Date()) {
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
}
}
const result = await generateApiToken(
auth.user.id,
name.trim(),
expiresAt || null
);
await audit(event, 'create', 'api_token', {
entityId: String(result.id),
entityName: name.trim(),
description: `API token "${name.trim()}" created`
});
return json({
id: result.id,
token: result.token,
tokenPrefix: result.tokenPrefix
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
};
@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { revokeApiToken } from '$lib/server/api-tokens';
import { isAuthEnabled } from '$lib/server/auth';
import { audit } from '$lib/server/audit';
/**
* DELETE /api/auth/tokens/[id] - Revoke an API token
*/
export const DELETE: RequestHandler = async (event) => {
const { cookies, params } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokenId = parseInt(params.id);
if (isNaN(tokenId)) {
return json({ error: 'Invalid token ID' }, { status: 400 });
}
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
if (!success) {
return json({ error: 'Token not found or access denied' }, { status: 404 });
}
await audit(event, 'delete', 'api_token', {
entityId: params.id,
description: `API token revoked`
});
return json({ success: true });
};
@@ -7,6 +7,7 @@ import {
deleteAutoUpdateSchedule
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url }) => {
try {
@@ -38,7 +39,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
}
};
export const POST: RequestHandler = async ({ params, url, request }) => {
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
@@ -101,7 +107,12 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
}
};
export const DELETE: RequestHandler = async ({ params, url }) => {
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
+19 -1
View File
@@ -4,7 +4,7 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -33,6 +33,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const details = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(details.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
if (secretKeys.size > 0) {
details.Config.Env = details.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(details);
} catch (error: any) {
if (error?.statusCode === 404) {
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { getSecretKeyNames } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -20,6 +21,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerData = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(containerData.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
if (secretKeys.size > 0) {
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
@@ -25,6 +25,29 @@ export const POST: RequestHandler = async (event) => {
const body = await request.json();
const { startAfterUpdate, repullImage, ...options } = body;
// Resolve masked secret values (***) back to real values from the current container.
// The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets.
if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) {
const currentData = await inspectContainer(params.id, envIdNum);
const currentEnvMap = new Map<string, string>();
for (const entry of currentData.Config?.Env || []) {
const eqIdx = entry.indexOf('=');
if (eqIdx !== -1) {
currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1));
}
}
options.env = options.env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
const value = entry.substring(eqIdx + 1);
if (value === '***' && currentEnvMap.has(key)) {
return `${key}=${currentEnvMap.get(key)}`;
}
return entry;
});
}
if (repullImage) {
console.log(`Pulling image...`);
try {
+2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
@@ -130,6 +131,7 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
invalidateTokenCacheForUser(user.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
+6
View File
@@ -7,6 +7,7 @@ import {
getHostname
} from '$lib/server/license';
import { authorize } from '$lib/server/authorize';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/license - Get current license status
// Any authenticated user can view license status (needed to determine if RBAC applies)
@@ -59,6 +60,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
);
}
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({
success: true,
license: result.license
@@ -81,6 +85,8 @@ export const DELETE: RequestHandler = async ({ cookies }) => {
try {
await deactivateLicense();
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({ success: true });
} catch (error) {
console.error('Error deactivating license:', error);
+4 -2
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db';
import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/profile - Get current user's profile
export const GET: RequestHandler = async ({ cookies }) => {
@@ -85,8 +86,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
}
updateData.passwordHash = await hashPassword(data.newPassword);
// Invalidate other sessions on password change
deleteUserSessions(currentUser.id);
// Invalidate other sessions and token cache on password change
await deleteUserSessions(currentUser.id);
invalidateTokenCacheForUser(currentUser.id);
}
const user = await dbUpdateUser(currentUser.id, updateData);
+7
View File
@@ -8,6 +8,7 @@ import {
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/roles/[id] - Get a specific role
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -75,6 +76,9 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Failed to update role' }, { status: 500 });
}
// Clear token cache — any cached user with this role has stale permissions
clearTokenCache();
// Compute diff for audit
const diff = computeAuditDiff(existingRole, role);
@@ -128,6 +132,9 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Failed to delete role' }, { status: 500 });
}
// Clear token cache — users with this role may have stale cached permissions
clearTokenCache();
// Audit log
await auditRole(event, 'delete', id, role.name);
@@ -13,8 +13,14 @@ import {
deleteImagePruneSettings
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -11,8 +11,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -7,8 +7,14 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -8,6 +8,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params }) => {
try {
@@ -28,7 +29,12 @@ export const GET: RequestHandler = async ({ params }) => {
}
};
export const DELETE: RequestHandler = async ({ params }) => {
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id, 10);
if (isNaN(id)) {
+4 -1
View File
@@ -144,10 +144,13 @@ export const POST: RequestHandler = async (event) => {
}
// ALWAYS save compose file first - deployStack expects it to exist
await saveStackComposeFile(name, compose, true, envIdNum, {
const saveResult = await saveStackComposeFile(name, compose, true, envIdNum, {
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 400 });
}
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
+2
View File
@@ -11,6 +11,7 @@ import {
} from '$lib/server/db';
import { hashPassword, createUserSession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { auditUser } from '$lib/server/audit';
// GET /api/users - List all users
@@ -108,6 +109,7 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, null);
invalidateTokenCacheForUser(user.id);
}
}
+12 -3
View File
@@ -16,6 +16,7 @@ import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
@@ -125,6 +126,7 @@ export const PUT: RequestHandler = async (event) => {
if (isDeactivating) {
updateData.isActive = false;
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
// Disable authentication
@@ -142,6 +144,7 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
await removeUserRole(userId, adminRole.id, null);
}
invalidateTokenCacheForUser(userId);
}
return json({
@@ -170,9 +173,10 @@ export const PUT: RequestHandler = async (event) => {
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
// If deactivating, invalidate all sessions
// If deactivating, invalidate all sessions and token cache
if (!data.isActive) {
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
}
}
@@ -183,8 +187,9 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
updateData.passwordHash = await hashPassword(data.password);
// Invalidate all sessions on password change (except current)
// Invalidate all sessions and token cache on password change
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
const user = await dbUpdateUser(userId, updateData);
@@ -198,8 +203,10 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
if (shouldPromote) {
await assignUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
} else if (shouldDemote) {
await removeUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
}
}
@@ -284,6 +291,7 @@ export const DELETE: RequestHandler = async (event) => {
// User confirmed - proceed with deletion and disable auth
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
return json({ error: 'Failed to delete user' }, { status: 500 });
@@ -299,8 +307,9 @@ export const DELETE: RequestHandler = async (event) => {
}
}
// Delete all sessions first
// Delete all sessions and invalidate token cache
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
@@ -10,6 +10,7 @@ import {
getRole
} from '$lib/server/db';
import { auditUser } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -69,6 +70,7 @@ export const POST: RequestHandler = async (event) => {
}
const userRole = await assignUserRole(userId, roleId, environmentId);
invalidateTokenCacheForUser(userId);
// Audit log - role assigned
const role = await getRole(roleId);
@@ -117,6 +119,7 @@ export const DELETE: RequestHandler = async (event) => {
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
invalidateTokenCacheForUser(userId);
// Audit log - role removed
if (user) {