Update
This commit is contained in:
1 parent
312dd3b3ad
commit
4f4fb0baf2
11 files changed
+158
-15
No files matched your search
@@ -2,6 +2,7 @@ TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
APP_PORT=3910
|
||||
|
||||
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -4,6 +4,7 @@ APP_PORT=3910
|
||||
POSTGRES_DB=tickettracker
|
||||
POSTGRES_USER=tickettracker
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
APP_PORT=3910
|
||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
COOKIE_SECURE=false
|
||||
|
||||
+16
-4
@@ -8,6 +8,7 @@ import type pg from "pg";
|
||||
import { migrate } from "./migrations.js";
|
||||
import { pool, query, withTransaction } from "./db.js";
|
||||
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import {
|
||||
databaseExportTables as backupTables,
|
||||
exportDatabaseSql,
|
||||
@@ -399,10 +400,13 @@ type ZammadSyncResult = {
|
||||
|
||||
async function getAppSetting(key: string) {
|
||||
const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]);
|
||||
return result.rows[0]?.value ?? null;
|
||||
const value = result.rows[0]?.value ?? null;
|
||||
return key === zammadApiKeySettingKey ? decryptSecret(value) : value;
|
||||
}
|
||||
|
||||
async function setAppSetting(key: string, value: string) {
|
||||
const storedValue = key === zammadApiKeySettingKey ? encryptSecret(value) : value;
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
@@ -410,7 +414,7 @@ async function setAppSetting(key: string, value: string) {
|
||||
ON CONFLICT (key)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
|
||||
`,
|
||||
[key, value]
|
||||
[key, storedValue]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,7 +524,7 @@ async function getZammadCredentialsForUser(userId: string) {
|
||||
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
|
||||
}
|
||||
|
||||
const apiToken = userResult.rows[0]?.zammad_api_token ?? null;
|
||||
const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token);
|
||||
|
||||
if (!apiToken) {
|
||||
throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden");
|
||||
@@ -4074,7 +4078,15 @@ app.patch("/api/auth/me", async (req, res) => {
|
||||
WHERE id = $4
|
||||
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
|
||||
`,
|
||||
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId]
|
||||
[
|
||||
username,
|
||||
displayName,
|
||||
newPassword ? await hashPassword(newPassword) : null,
|
||||
user.id,
|
||||
updatesZammadToken,
|
||||
zammadApiToken ? encryptSecret(zammadApiToken) : null,
|
||||
zammadUserId
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { query } from "./db.js";
|
||||
import { encryptSecret, isEncryptedSecret } from "./secrets.js";
|
||||
|
||||
const zammadApiKeySettingKey = "zammad.api_key";
|
||||
|
||||
async function findMigrationAdmin() {
|
||||
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
|
||||
@@ -39,6 +42,27 @@ async function ensureClosureOwnership(table: string, column: string, primaryKeyC
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
|
||||
}
|
||||
|
||||
async function encryptStoredApiSecrets() {
|
||||
const userTokenResult = await query<{ id: string; zammad_api_token: string | null }>(`
|
||||
SELECT id::text AS id, zammad_api_token
|
||||
FROM users
|
||||
WHERE zammad_api_token IS NOT NULL;
|
||||
`);
|
||||
|
||||
for (const row of userTokenResult.rows) {
|
||||
if (row.zammad_api_token && !isEncryptedSecret(row.zammad_api_token)) {
|
||||
await query("UPDATE users SET zammad_api_token = $1, updated_at = now() WHERE id = $2;", [encryptSecret(row.zammad_api_token), row.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]);
|
||||
const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null;
|
||||
|
||||
if (zammadApiKey && !isEncryptedSecret(zammadApiKey)) {
|
||||
await query("UPDATE app_settings SET value = $1, updated_at = now() WHERE key = $2;", [encryptSecret(zammadApiKey), zammadApiKeySettingKey]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrate() {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -91,6 +115,8 @@ export async function migrate() {
|
||||
);
|
||||
`);
|
||||
|
||||
await encryptStoredApiSecrets();
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const encryptedSecretPrefix = "ttenc:v1:";
|
||||
|
||||
let warnedAboutFallbackKey = false;
|
||||
let cachedKey: Buffer | null = null;
|
||||
|
||||
function getEncryptionMaterial() {
|
||||
const configuredKey = process.env.SECRET_ENCRYPTION_KEY ?? process.env.TICKETTRACKER_SECRET_KEY;
|
||||
|
||||
if (configuredKey) {
|
||||
return configuredKey;
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("SECRET_ENCRYPTION_KEY is required to encrypt database secrets");
|
||||
}
|
||||
|
||||
if (!warnedAboutFallbackKey) {
|
||||
console.warn("SECRET_ENCRYPTION_KEY is not set; deriving database secret encryption from DATABASE_URL.");
|
||||
warnedAboutFallbackKey = true;
|
||||
}
|
||||
|
||||
return process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
function getEncryptionKey() {
|
||||
if (!cachedKey) {
|
||||
cachedKey = crypto.createHash("sha256").update(getEncryptionMaterial()).digest();
|
||||
}
|
||||
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export function isEncryptedSecret(value: string | null | undefined) {
|
||||
return typeof value === "string" && value.startsWith(encryptedSecretPrefix);
|
||||
}
|
||||
|
||||
export function encryptSecret(value: string) {
|
||||
if (isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${encryptedSecretPrefix}${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parts = value.slice(encryptedSecretPrefix.length).split(".");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Encrypted database secret has an invalid format");
|
||||
}
|
||||
|
||||
const [ivText, tagText, encryptedText] = parts;
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", getEncryptionKey(), Buffer.from(ivText, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
|
||||
|
||||
return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decryptSecret, encryptSecret, isEncryptedSecret } from "../src/secrets.js";
|
||||
|
||||
test("database secrets are encrypted and can be decrypted", () => {
|
||||
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
|
||||
|
||||
const encrypted = encryptSecret("zammad-token-with-specials-!$%&/()=?");
|
||||
|
||||
assert.equal(isEncryptedSecret(encrypted), true);
|
||||
assert.notEqual(encrypted, "zammad-token-with-specials-!$%&/()=?");
|
||||
assert.equal(decryptSecret(encrypted), "zammad-token-with-specials-!$%&/()=?");
|
||||
});
|
||||
|
||||
test("plain legacy secrets are read as-is until migration encrypts them", () => {
|
||||
assert.equal(decryptSecret("legacy-token"), "legacy-token");
|
||||
assert.equal(decryptSecret(null), null);
|
||||
});
|
||||
|
||||
test("already encrypted secrets are not encrypted twice", () => {
|
||||
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
|
||||
|
||||
const encrypted = encryptSecret("zammad-token");
|
||||
|
||||
assert.equal(encryptSecret(encrypted), encrypted);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker}
|
||||
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
|
||||
PORT: 3000
|
||||
BACKEND_PORT: 3001
|
||||
API_PROXY_TARGET: http://127.0.0.1:3001
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
|
||||
PORT: 3000
|
||||
BACKEND_PORT: 3001
|
||||
API_PROXY_TARGET: http://127.0.0.1:3001
|
||||
|
||||
@@ -1245,7 +1245,7 @@ export function App() {
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
|
||||
<Command />
|
||||
<span className="font-semibold text-base">TicketTracker</span>
|
||||
<span className="font-semibold text-base">TicketTracker </span><span className="font-medium text-muted-foreground">v1.0.0-rc1</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -92,18 +92,18 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
|
||||
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
|
||||
return (
|
||||
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{group.ticketNumber}</span>
|
||||
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
||||
<Badge variant="warning">{group.openCount} offen</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
||||
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
|
||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
|
||||
Ticket öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -126,8 +126,9 @@ function CrmDayItem({
|
||||
const isAcknowledged = Boolean(item.acknowledged);
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<div className={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{item.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={item.ticket_number} />
|
||||
@@ -141,16 +142,17 @@ function CrmDayItem({
|
||||
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
|
||||
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
|
||||
Tag öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user