RBAC 2.0
This commit is contained in:
Vendored
+3
-1
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"testing.exclude": {
|
||||
"**/Overlord-Server/dist/**": true
|
||||
"**/Overlord-Server/dist/**": true,
|
||||
"**/dist/Overlord-Client/**": true,
|
||||
"**/dist-clients/**": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var DefaultMutex = ""
|
||||
var DefaultID = ""
|
||||
var DefaultCountry = ""
|
||||
var DefaultAgentToken = ""
|
||||
var DefaultBuildTag = ""
|
||||
|
||||
const settingsFile = "config/settings.json"
|
||||
const serverIndexFile = "config/server_index.json"
|
||||
@@ -60,6 +61,7 @@ type Config struct {
|
||||
TLSClientCert string
|
||||
TLSClientKey string
|
||||
AgentToken string
|
||||
BuildTag string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -159,6 +161,7 @@ func Load() Config {
|
||||
TLSClientCert: tlsClientCert,
|
||||
TLSClientKey: tlsClientKey,
|
||||
AgentToken: agentToken,
|
||||
BuildTag: strings.TrimSpace(DefaultBuildTag),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +388,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
||||
Monitors: capture.MonitorCount(),
|
||||
MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()),
|
||||
Country: cfg.Country,
|
||||
BuildTag: cfg.BuildTag,
|
||||
}
|
||||
|
||||
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ type Hello struct {
|
||||
Monitors int `msgpack:"monitors"`
|
||||
MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"`
|
||||
Country string `msgpack:"country,omitempty"`
|
||||
BuildTag string `msgpack:"buildTag,omitempty"`
|
||||
}
|
||||
|
||||
type MonitorInfo struct {
|
||||
|
||||
@@ -57,6 +57,113 @@ func TestHelloMarshaling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloBuildTagMarshaling(t *testing.T) {
|
||||
t.Run("with build tag", func(t *testing.T) {
|
||||
hello := Hello{
|
||||
Type: "hello",
|
||||
ID: "test-id",
|
||||
HWID: "test-hwid",
|
||||
Host: "test-host",
|
||||
OS: "test-os",
|
||||
Arch: "test-arch",
|
||||
Version: "1.0.0",
|
||||
User: "test-user",
|
||||
BuildTag: "abc-123-def-456",
|
||||
}
|
||||
|
||||
data, err := msgpack.Marshal(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal Hello with BuildTag: %v", err)
|
||||
}
|
||||
|
||||
var decoded Hello
|
||||
if err := msgpack.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Failed to unmarshal Hello with BuildTag: %v", err)
|
||||
}
|
||||
|
||||
if decoded.BuildTag != "abc-123-def-456" {
|
||||
t.Errorf("BuildTag mismatch: got %q, want %q", decoded.BuildTag, "abc-123-def-456")
|
||||
}
|
||||
if decoded.ID != hello.ID {
|
||||
t.Errorf("ID mismatch: got %s, want %s", decoded.ID, hello.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without build tag (omitempty)", func(t *testing.T) {
|
||||
hello := Hello{
|
||||
Type: "hello",
|
||||
ID: "test-id",
|
||||
HWID: "test-hwid",
|
||||
Host: "test-host",
|
||||
OS: "test-os",
|
||||
Arch: "test-arch",
|
||||
Version: "1.0.0",
|
||||
User: "test-user",
|
||||
}
|
||||
|
||||
data, err := msgpack.Marshal(hello)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal Hello without BuildTag: %v", err)
|
||||
}
|
||||
|
||||
// Verify buildTag key is not present in the wire data (omitempty)
|
||||
if bytes.Contains(data, []byte("buildTag")) {
|
||||
t.Error("Expected buildTag to be omitted from wire data when empty")
|
||||
}
|
||||
|
||||
var decoded Hello
|
||||
if err := msgpack.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Failed to unmarshal Hello without BuildTag: %v", err)
|
||||
}
|
||||
|
||||
if decoded.BuildTag != "" {
|
||||
t.Errorf("BuildTag should be empty, got %q", decoded.BuildTag)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server-side decode without build tag field", func(t *testing.T) {
|
||||
// Simulate an older client that doesn't send buildTag at all
|
||||
type OldHello struct {
|
||||
Type string `msgpack:"type"`
|
||||
ID string `msgpack:"id"`
|
||||
HWID string `msgpack:"hwid"`
|
||||
Host string `msgpack:"host"`
|
||||
OS string `msgpack:"os"`
|
||||
Arch string `msgpack:"arch"`
|
||||
Version string `msgpack:"version"`
|
||||
User string `msgpack:"user"`
|
||||
}
|
||||
|
||||
old := OldHello{
|
||||
Type: "hello",
|
||||
ID: "old-client",
|
||||
HWID: "old-hwid",
|
||||
Host: "old-host",
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Version: "0.9.0",
|
||||
User: "old-user",
|
||||
}
|
||||
|
||||
data, err := msgpack.Marshal(old)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal OldHello: %v", err)
|
||||
}
|
||||
|
||||
var decoded Hello
|
||||
if err := msgpack.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Failed to unmarshal OldHello into Hello: %v", err)
|
||||
}
|
||||
|
||||
if decoded.BuildTag != "" {
|
||||
t.Errorf("BuildTag should default to empty for old client, got %q", decoded.BuildTag)
|
||||
}
|
||||
if decoded.ID != "old-client" {
|
||||
t.Errorf("ID mismatch: got %s, want old-client", decoded.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPingPongMarshaling(t *testing.T) {
|
||||
ping := Ping{
|
||||
Type: "ping",
|
||||
|
||||
@@ -141,7 +141,7 @@ async function init() {
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role === "admin" || data.role === "operator") {
|
||||
if (data.role === "admin" || data.role === "operator" || data.canBuild) {
|
||||
buildLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -149,10 +149,10 @@ async function init() {
|
||||
scriptsLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role !== "admin" && data.role !== "operator") {
|
||||
if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) {
|
||||
buildBtn.disabled = true;
|
||||
buildBtn.innerHTML =
|
||||
'<i class="fa-solid fa-lock"></i> <span>Build requires admin/operator role</span>';
|
||||
'<i class="fa-solid fa-lock"></i> <span>Build requires permission</span>';
|
||||
}
|
||||
|
||||
await loadServerVersion();
|
||||
@@ -539,13 +539,14 @@ async function deleteBuild(buildId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/build/${buildId}/delete`, {
|
||||
const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete build");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to delete build");
|
||||
}
|
||||
|
||||
const buildElement = document.getElementById(`build-${buildId}`);
|
||||
|
||||
@@ -186,7 +186,7 @@ async function loadCurrentUser() {
|
||||
usersLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.role === "admin" || currentUser.role === "operator") {
|
||||
if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) {
|
||||
buildLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +550,7 @@ async function checkAuth() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
document.getElementById("plugins-link")?.classList.remove("hidden");
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
} else if (data.role === "operator") {
|
||||
} else if (data.role === "operator" || data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
pluginsLink,
|
||||
scriptsLink,
|
||||
logsLink,
|
||||
notificationsLink,
|
||||
} = refs;
|
||||
|
||||
if (!user || !usernameDisplay || !roleBadge) return;
|
||||
@@ -68,6 +69,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
}
|
||||
if (user.role === "admin" || user.role === "operator") {
|
||||
buildLink?.classList.remove("hidden");
|
||||
notificationsLink?.classList.remove("hidden");
|
||||
}
|
||||
if (user.role !== "viewer") {
|
||||
scriptsLink?.classList.remove("hidden");
|
||||
|
||||
@@ -62,7 +62,7 @@ export function mountNav(host) {
|
||||
<a
|
||||
href="/notifications"
|
||||
id="notifications-link"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
><i class="fa-solid fa-bell text-yellow-400"></i> Notifications</a
|
||||
>
|
||||
<a
|
||||
@@ -128,5 +128,6 @@ export function mountNav(host) {
|
||||
pluginsLink: document.getElementById("plugins-link"),
|
||||
scriptsLink: document.getElementById("scripts-link"),
|
||||
logsLink: document.getElementById("logs-link"),
|
||||
notificationsLink: document.getElementById("notifications-link"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ async function checkAuth() {
|
||||
document.getElementById("users-link")?.classList.remove("hidden");
|
||||
document.getElementById("plugins-link")?.classList.remove("hidden");
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
} else if (data.role === "operator") {
|
||||
} else if (data.role === "operator" || data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ async function checkAuth() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role === "viewer") {
|
||||
alert("Access denied. Operator or Admin role required.");
|
||||
window.location.href = "/";
|
||||
|
||||
@@ -19,6 +19,9 @@ const prefsForm = document.getElementById("prefs-form");
|
||||
const prefNotificationsInput = document.getElementById("pref-notifications");
|
||||
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
|
||||
|
||||
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
|
||||
const saveMyTelegramBtn = document.getElementById("save-my-telegram");
|
||||
|
||||
const bansTableBody = document.getElementById("bans-table-body");
|
||||
const bansPermissionNote = document.getElementById("bans-permission-note");
|
||||
const refreshBansBtn = document.getElementById("refresh-bans-btn");
|
||||
@@ -447,6 +450,46 @@ async function runCertbotAutoSetup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyTelegram() {
|
||||
if (!myTelegramChatIdInput) return;
|
||||
if (currentUser?.telegramChatId) {
|
||||
myTelegramChatIdInput.value = currentUser.telegramChatId;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch("/api/settings/telegram", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
myTelegramChatIdInput.value = data.telegramChatId || "";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMyTelegram() {
|
||||
if (!myTelegramChatIdInput) return;
|
||||
const chatId = myTelegramChatIdInput.value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/telegram", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ telegramChatId: chatId }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMessage(data.error || "Failed to save Telegram settings.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
myTelegramChatIdInput.value = data.telegramChatId || "";
|
||||
showMessage(chatId ? "Telegram chat ID saved." : "Telegram notifications disabled.");
|
||||
} catch {
|
||||
showMessage("Failed to save Telegram settings.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
if (!currentUser) return;
|
||||
|
||||
@@ -537,12 +580,14 @@ async function init() {
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
loadPrefs();
|
||||
await loadMyTelegram();
|
||||
await loadSecurityPolicy();
|
||||
await loadTlsSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
passwordForm.addEventListener("submit", updatePassword);
|
||||
prefsForm.addEventListener("submit", savePrefs);
|
||||
if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram);
|
||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||
|
||||
@@ -68,6 +68,10 @@ async function getCurrentUser() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.role !== "admin") {
|
||||
alert("Access denied. Admin role required.");
|
||||
window.location.href = "/";
|
||||
@@ -149,6 +153,13 @@ function renderUsers() {
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
${getRoleBadge(user.role)}
|
||||
${user.role !== "admin" ? `
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-colors ${user.can_build ? 'bg-green-900/30 text-green-300 border border-green-800 hover:bg-green-900/50' : 'bg-slate-700/30 text-slate-500 border border-slate-700 hover:bg-slate-700/50'}"
|
||||
data-action="toggle-build" data-user-id="${user.id}" data-username="${escapeHtml(user.username)}" data-can-build="${user.can_build ? 1 : 0}"
|
||||
title="${user.can_build ? 'Can build (click to revoke)' : 'Cannot build (click to grant)'}">
|
||||
<i class="fa-solid fa-hammer mr-1"></i>${user.can_build ? 'Build' : 'No Build'}
|
||||
</span>
|
||||
` : ''}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-400">
|
||||
${formatDate(user.created_at)}
|
||||
@@ -234,6 +245,15 @@ function attachActionListeners() {
|
||||
}
|
||||
|
||||
const listener = (e) => {
|
||||
const toggleBuild = e.target.closest("[data-action='toggle-build']");
|
||||
if (toggleBuild) {
|
||||
const userId = parseInt(toggleBuild.dataset.userId);
|
||||
const username = toggleBuild.dataset.username;
|
||||
const canBuild = toggleBuild.dataset.canBuild === "1";
|
||||
toggleBuildPermission(userId, username, canBuild);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest(".user-action-btn");
|
||||
if (!btn) return;
|
||||
|
||||
@@ -429,5 +449,29 @@ window.configureClientAccess = async function (userId, username, role) {
|
||||
window.location.href = `/user-client-access?${params.toString()}`;
|
||||
};
|
||||
|
||||
window.toggleBuildPermission = async function (userId, username, currentCanBuild) {
|
||||
const newVal = !currentCanBuild;
|
||||
if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${userId}/can-build`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ canBuild: newVal }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
await loadUsers();
|
||||
} else {
|
||||
alert(data.error || "Failed to update build permission");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Toggle build permission error:", err);
|
||||
alert("Network error. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
getCurrentUser();
|
||||
loadUsers();
|
||||
|
||||
@@ -104,6 +104,36 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Notification Settings</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
Receive Telegram notifications for clients you have access to.
|
||||
</p>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Your Telegram Chat ID</span>
|
||||
<input
|
||||
id="my-telegram-chat-id"
|
||||
type="text"
|
||||
placeholder="e.g. 123456789 (leave empty to disable)"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-slate-400">
|
||||
Send <code>/start</code> to the Telegram bot, then use <code>/id</code> or
|
||||
<a href="https://t.me/userinfobot" target="_blank" rel="noopener" class="text-sky-400 hover:underline">@userinfobot</a>
|
||||
to find your chat ID.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="save-my-telegram"
|
||||
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-brands fa-telegram mr-2"></i>Save Telegram Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Security Policy</h3>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
saveBuild,
|
||||
getBuild,
|
||||
getBuildByTag,
|
||||
getAllBuilds,
|
||||
deleteBuild,
|
||||
type BuildRecord,
|
||||
} from "./db";
|
||||
import {
|
||||
canUserAccessClient,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserByUsername,
|
||||
getUserClientAccessScope,
|
||||
listUserClientAccessRules,
|
||||
setUserClientAccessScope,
|
||||
} from "./users";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
const createdBuildIds: string[] = [];
|
||||
|
||||
async function createTempUser(role: "viewer" | "operator" | "admin") {
|
||||
const username = `bt_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
|
||||
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserByUsername(username);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function cleanupCreatedUsers() {
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") {
|
||||
deleteUser(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupCreatedBuilds() {
|
||||
while (createdBuildIds.length > 0) {
|
||||
const id = createdBuildIds.pop();
|
||||
if (typeof id === "string") {
|
||||
deleteBuild(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveTempBuild(overrides: Partial<BuildRecord> = {}): BuildRecord {
|
||||
const build: BuildRecord = {
|
||||
id: `test-build-${Date.now().toString(36)}${Math.floor(Math.random() * 10000).toString(36)}`,
|
||||
status: "completed",
|
||||
startTime: Date.now(),
|
||||
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
files: [{ name: "agent", filename: "agent.exe", platform: "windows/amd64", size: 1024 }],
|
||||
...overrides,
|
||||
};
|
||||
saveBuild(build);
|
||||
createdBuildIds.push(build.id);
|
||||
return build;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
});
|
||||
|
||||
describe("build tag DB operations", () => {
|
||||
test("saveBuild stores buildTag and builtByUserId", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
const build = saveTempBuild({
|
||||
buildTag: "test-tag-abc123",
|
||||
builtByUserId: operator.id,
|
||||
});
|
||||
|
||||
const retrieved = getBuild(build.id);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.buildTag).toBe("test-tag-abc123");
|
||||
expect(retrieved!.builtByUserId).toBe(operator.id);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getBuildByTag finds build by its tag", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
const tag = `tag-${Date.now().toString(36)}`;
|
||||
saveTempBuild({
|
||||
buildTag: tag,
|
||||
builtByUserId: operator.id,
|
||||
});
|
||||
|
||||
const found = getBuildByTag(tag);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.buildTag).toBe(tag);
|
||||
expect(found!.builtByUserId).toBe(operator.id);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getBuildByTag returns null for unknown tag", () => {
|
||||
const found = getBuildByTag("nonexistent-tag-xyz");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
test("getAllBuilds includes buildTag and builtByUserId", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
const tag = `alltag-${Date.now().toString(36)}`;
|
||||
const build = saveTempBuild({
|
||||
buildTag: tag,
|
||||
builtByUserId: operator.id,
|
||||
});
|
||||
|
||||
const all = getAllBuilds();
|
||||
const found = all.find((b) => b.id === build.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.buildTag).toBe(tag);
|
||||
expect(found!.builtByUserId).toBe(operator.id);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("build without tag has undefined buildTag", () => {
|
||||
const build = saveTempBuild();
|
||||
const retrieved = getBuild(build.id);
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.buildTag).toBeUndefined();
|
||||
expect(retrieved!.builtByUserId).toBeUndefined();
|
||||
cleanupCreatedBuilds();
|
||||
});
|
||||
});
|
||||
|
||||
describe("build tag auto-allowlist logic", () => {
|
||||
test("operator with scope 'none' gets upgraded to 'allowlist' when build connects", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
expect(getUserClientAccessScope(operator.id)).toBe("none");
|
||||
|
||||
const tag = `auto-${Date.now().toString(36)}`;
|
||||
saveTempBuild({
|
||||
buildTag: tag,
|
||||
builtByUserId: operator.id,
|
||||
});
|
||||
|
||||
const build = getBuildByTag(tag);
|
||||
expect(build).not.toBeNull();
|
||||
expect(build!.builtByUserId).toBe(operator.id);
|
||||
|
||||
// Simulate what handleBuildTagConnection does:
|
||||
// 1. Look up the build by tag
|
||||
// 2. If scope is "none", upgrade to "allowlist"
|
||||
// 3. Add client to allowlist
|
||||
const clientId = `test-client-${Date.now().toString(36)}`;
|
||||
const currentScope = getUserClientAccessScope(operator.id);
|
||||
if (currentScope === "none") {
|
||||
setUserClientAccessScope(operator.id, "allowlist");
|
||||
}
|
||||
const { setUserClientAccessRule } = await import("./users");
|
||||
setUserClientAccessRule(operator.id, clientId, "allow");
|
||||
|
||||
expect(getUserClientAccessScope(operator.id)).toBe("allowlist");
|
||||
expect(canUserAccessClient(operator.id, operator.role, clientId)).toBe(true);
|
||||
// Other clients should NOT be accessible
|
||||
expect(canUserAccessClient(operator.id, operator.role, "other-client")).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("operator with existing allowlist gets new client added without scope change", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
setUserClientAccessScope(operator.id, "allowlist");
|
||||
|
||||
const { setUserClientAccessRule } = await import("./users");
|
||||
setUserClientAccessRule(operator.id, "existing-client", "allow");
|
||||
|
||||
// Now simulate build tag connection adding a new client
|
||||
const newClientId = `new-client-${Date.now().toString(36)}`;
|
||||
const currentScope = getUserClientAccessScope(operator.id);
|
||||
expect(currentScope).toBe("allowlist");
|
||||
|
||||
setUserClientAccessRule(operator.id, newClientId, "allow");
|
||||
|
||||
expect(canUserAccessClient(operator.id, operator.role, "existing-client")).toBe(true);
|
||||
expect(canUserAccessClient(operator.id, operator.role, newClientId)).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer with denylist scope is unmodified on build tag connection", async () => {
|
||||
try {
|
||||
const viewer = await createTempUser("viewer");
|
||||
setUserClientAccessScope(viewer.id, "denylist");
|
||||
|
||||
const { setUserClientAccessRule } = await import("./users");
|
||||
setUserClientAccessRule(viewer.id, "blocked-client", "deny");
|
||||
|
||||
const clientId = `new-auto-${Date.now().toString(36)}`;
|
||||
const currentScope = getUserClientAccessScope(viewer.id);
|
||||
// With denylist, handleBuildTagConnection does nothing (client allowed by default)
|
||||
expect(currentScope).toBe("denylist");
|
||||
expect(canUserAccessClient(viewer.id, viewer.role, clientId)).toBe(true);
|
||||
expect(canUserAccessClient(viewer.id, viewer.role, "blocked-client")).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("admin does not get rules added (already has full access)", async () => {
|
||||
try {
|
||||
const admin = await createTempUser("admin");
|
||||
const tag = `admin-tag-${Date.now().toString(36)}`;
|
||||
saveTempBuild({
|
||||
buildTag: tag,
|
||||
builtByUserId: admin.id,
|
||||
});
|
||||
|
||||
// handleBuildTagConnection returns early for admin
|
||||
expect(getUserClientAccessScope(admin.id)).toBe("all");
|
||||
const rules = listUserClientAccessRules(admin.id);
|
||||
expect(rules.length).toBe(0);
|
||||
} finally {
|
||||
cleanupCreatedBuilds();
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,16 @@ db.run(`
|
||||
status TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
files TEXT NOT NULL
|
||||
files TEXT NOT NULL,
|
||||
build_tag TEXT,
|
||||
built_by_user_id INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN build_tag TEXT`); } catch {}
|
||||
try { db.run(`ALTER TABLE builds ADD COLUMN built_by_user_id INTEGER`); } catch {}
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_builds_build_tag ON builds(build_tag);`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS notification_screenshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -566,17 +572,21 @@ export interface BuildRecord {
|
||||
version?: string;
|
||||
size: number;
|
||||
}>;
|
||||
buildTag?: string;
|
||||
builtByUserId?: number;
|
||||
}
|
||||
|
||||
export function saveBuild(build: BuildRecord) {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
`INSERT OR REPLACE INTO builds (id, status, start_time, expires_at, files, build_tag, built_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
build.id,
|
||||
build.status,
|
||||
build.startTime,
|
||||
build.expiresAt,
|
||||
JSON.stringify(build.files),
|
||||
build.buildTag || null,
|
||||
build.builtByUserId || null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,6 +600,23 @@ export function getBuild(id: string): BuildRecord | null {
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuildByTag(buildTag: string): BuildRecord | null {
|
||||
const row = db.query<any>(`SELECT * FROM builds WHERE build_tag = ?`).get(buildTag);
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,6 +630,8 @@ export function getAllBuilds(): BuildRecord[] {
|
||||
startTime: row.start_time,
|
||||
expiresAt: row.expires_at,
|
||||
files: JSON.parse(row.files),
|
||||
buildTag: row.build_tag || undefined,
|
||||
builtByUserId: row.built_by_user_id || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { decodeMessage, encodeMessage, type WireMessage, type PluginManifest } f
|
||||
import { logger } from "./logger";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists } from "./db";
|
||||
import { upsertClientRow, setOnlineState, listClients, markAllClientsOffline, getBuild, getBuildByTag, getAllBuilds, deleteExpiredBuilds, deleteBuild, getNotificationScreenshot, clearNotificationScreenshots, deleteClientRow, getClientIp, banIp, isIpBanned, clientExists } from "./db";
|
||||
import { handleFrame, handleHello, handlePing, handlePong } from "./wsHandlers";
|
||||
import { getMessageByteLength, getMaxPayloadLimit, isAllowedClientMessageType } from "./wsValidation";
|
||||
import { ClientInfo, ClientRole } from "./types";
|
||||
@@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "./auth";
|
||||
import { loadConfig, getConfig } from "./config";
|
||||
import { flushAuditLogsSync } from "./auditLog";
|
||||
import { getUserById } from "./users";
|
||||
import { getUserById, getUsersWithTelegramChatId, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
|
||||
import { requireAuth, requirePermission } from "./rbac";
|
||||
import { metrics } from "./metrics";
|
||||
import { ensureDataDir } from "./paths";
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
takePendingNotificationScreenshot,
|
||||
type NotificationRecord,
|
||||
type PendingNotificationScreenshot,
|
||||
type PerUserTelegramRecipient,
|
||||
} from "./server/notification-delivery";
|
||||
import {
|
||||
ensurePluginExtracted as ensurePluginExtractedFromRoot,
|
||||
@@ -226,8 +227,17 @@ const storeNotificationScreenshotForPending = (
|
||||
width?: number,
|
||||
height?: number,
|
||||
) => storeNotificationScreenshot(notificationHistory, pending, bytes, format, width, height);
|
||||
const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) =>
|
||||
deliverNotificationWithScreenshot(record, getNotificationConfig);
|
||||
const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) => {
|
||||
const getPerUserRecipients = (clientId: string): PerUserTelegramRecipient[] => {
|
||||
const usersWithTelegram = getUsersWithTelegramChatId();
|
||||
return usersWithTelegram.map((u) => ({
|
||||
userId: u.id,
|
||||
chatId: u.telegram_chat_id,
|
||||
canAccessClient: canUserAccessClient(u.id, u.role, clientId),
|
||||
}));
|
||||
};
|
||||
return deliverNotificationWithScreenshot(record, getNotificationConfig, getPerUserRecipients);
|
||||
};
|
||||
|
||||
const notificationPluginHandlers = createNotificationPluginHandlers({
|
||||
notificationHistory,
|
||||
@@ -356,6 +366,28 @@ async function startServer() {
|
||||
},
|
||||
};
|
||||
|
||||
function handleBuildTagConnection(clientId: string, buildTagValue: string) {
|
||||
if (!buildTagValue) return;
|
||||
const build = getBuildByTag(buildTagValue);
|
||||
if (!build || !build.builtByUserId) return;
|
||||
|
||||
const userId = build.builtByUserId;
|
||||
const user = getUserById(userId);
|
||||
if (!user) return;
|
||||
|
||||
if (user.role === "admin") return;
|
||||
|
||||
const currentScope = getUserClientAccessScope(userId);
|
||||
if (currentScope === "none") {
|
||||
setUserClientAccessScope(userId, "allowlist");
|
||||
}
|
||||
|
||||
if (currentScope === "none" || currentScope === "allowlist") {
|
||||
setUserClientAccessRule(userId, clientId, "allow");
|
||||
logger.info(`[build-tag] Auto-added client ${clientId} to user ${user.username}'s allowlist (build: ${build.id.substring(0, 8)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycleDeps = {
|
||||
maxClientPayloadBytes: MAX_WS_MESSAGE_BYTES_CLIENT,
|
||||
maxViewerPayloadBytes: MAX_WS_MESSAGE_BYTES_VIEWER,
|
||||
@@ -412,6 +444,7 @@ async function startServer() {
|
||||
notifyConsoleClosed,
|
||||
clearPendingNotificationScreenshots: notificationPluginHandlers.clearPendingNotificationScreenshots,
|
||||
notifyRemoteDesktopStatus,
|
||||
handleBuildTagConnection,
|
||||
};
|
||||
|
||||
const server = Bun.serve<SocketData>({
|
||||
|
||||
@@ -5,6 +5,7 @@ export type Permission =
|
||||
| "users:manage"
|
||||
| "clients:control"
|
||||
| "clients:view"
|
||||
| "clients:build"
|
||||
| "audit:view";
|
||||
|
||||
export function checkPermission(
|
||||
@@ -12,7 +13,7 @@ export function checkPermission(
|
||||
permission: Permission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return hasPermission(user.role, permission);
|
||||
return hasPermission(user.role, permission, user.userId);
|
||||
}
|
||||
|
||||
export function checkAnyPermission(
|
||||
@@ -20,7 +21,7 @@ export function checkAnyPermission(
|
||||
permissions: Permission[],
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return permissions.some((p) => hasPermission(user.role, p));
|
||||
return permissions.some((p) => hasPermission(user.role, p, user.userId));
|
||||
}
|
||||
|
||||
export function checkAllPermissions(
|
||||
@@ -28,7 +29,7 @@ export function checkAllPermissions(
|
||||
permissions: Permission[],
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return permissions.every((p) => hasPermission(user.role, p));
|
||||
return permissions.every((p) => hasPermission(user.role, p, user.userId));
|
||||
}
|
||||
|
||||
export function requireAuth(user: AuthenticatedUser | null): AuthenticatedUser {
|
||||
@@ -72,6 +73,8 @@ export function getPermissionDescription(permission: Permission): string {
|
||||
return "Control clients (execute commands, desktop, console, files)";
|
||||
case "clients:view":
|
||||
return "View connected clients";
|
||||
case "clients:build":
|
||||
return "Build client binaries";
|
||||
case "audit:view":
|
||||
return "View audit logs";
|
||||
default:
|
||||
@@ -99,6 +102,7 @@ export function getRolePermissions(role: UserRole): Permission[] {
|
||||
if (hasPermission(role, "clients:control"))
|
||||
permissions.push("clients:control");
|
||||
if (hasPermission(role, "clients:view")) permissions.push("clients:view");
|
||||
if (hasPermission(role, "clients:build")) permissions.push("clients:build");
|
||||
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
|
||||
|
||||
return permissions;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { $ } from "bun";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { saveBuild } from "../db";
|
||||
import { logger } from "../logger";
|
||||
import { getConfig } from "../config";
|
||||
@@ -64,6 +65,7 @@ type BuildProcessConfig = {
|
||||
persistenceMethod?: string;
|
||||
hideConsole?: boolean;
|
||||
noPrinting?: boolean;
|
||||
builtByUserId?: number;
|
||||
};
|
||||
|
||||
const VALID_PERSISTENCE_METHODS = new Set(['startup', 'registry', 'taskscheduler', 'wmi']);
|
||||
@@ -221,6 +223,9 @@ export async function startBuildProcess(
|
||||
sendToStream({ type: "output", text: "Mutex: disabled\n", level: "info" });
|
||||
}
|
||||
|
||||
const buildTag = uuidv4();
|
||||
sendToStream({ type: "output", text: `Build tag: ${buildTag}\n`, level: "info" });
|
||||
|
||||
for (const platform of platformsToBuild) {
|
||||
const [os, arch, ...rest] = platform.split("-");
|
||||
const goarm = arch === "armv7" ? "7" : undefined;
|
||||
@@ -325,6 +330,11 @@ export async function startBuildProcess(
|
||||
ldflags = ldflags ? `${ldflags} ${agentTokenFlag}` : agentTokenFlag;
|
||||
}
|
||||
|
||||
if (buildTag) {
|
||||
const buildTagFlag = `-X overlord-client/cmd/agent/config.DefaultBuildTag=${buildTag}`;
|
||||
ldflags = ldflags ? `${ldflags} ${buildTagFlag}` : buildTagFlag;
|
||||
}
|
||||
|
||||
if (config.hideConsole && os === "windows") {
|
||||
const hideConsoleFlag = "-H=windowsgui";
|
||||
ldflags = ldflags ? `${ldflags} ${hideConsoleFlag}` : hideConsoleFlag;
|
||||
@@ -414,6 +424,8 @@ export async function startBuildProcess(
|
||||
startTime: build.startTime,
|
||||
expiresAt: build.expiresAt,
|
||||
files: build.files as any,
|
||||
buildTag,
|
||||
builtByUserId: config.builtByUserId,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -177,6 +177,15 @@ async function postTelegramNotification(
|
||||
const chatId = (config.telegramChatId || "").trim();
|
||||
if (!token || !chatId) return;
|
||||
|
||||
await sendTelegramToChat(token, chatId, record, screenshot);
|
||||
}
|
||||
|
||||
async function sendTelegramToChat(
|
||||
token: string,
|
||||
chatId: string,
|
||||
record: NotificationRecord,
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const text = `🔔 Notification\nTitle: ${record.title}\nKeyword: ${record.keyword || "-"}\nClient: ${record.clientId}\nUser: ${record.user || "unknown"}\nHost: ${record.host || "unknown"}\nProcess: ${record.process || "unknown"}`;
|
||||
try {
|
||||
if (screenshot?.bytes?.length) {
|
||||
@@ -198,17 +207,51 @@ async function postTelegramNotification(
|
||||
body: JSON.stringify({ chat_id: chatId, text }),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("[notify] telegram delivery failed", err);
|
||||
logger.warn(`[notify] telegram delivery to chat ${chatId} failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export type PerUserTelegramRecipient = {
|
||||
userId: number;
|
||||
chatId: string;
|
||||
canAccessClient: boolean;
|
||||
};
|
||||
|
||||
async function postPerUserTelegramNotifications(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
getPerUserRecipients: (clientId: string) => PerUserTelegramRecipient[],
|
||||
screenshot?: NotificationScreenshotRecord | null,
|
||||
): Promise<void> {
|
||||
const config = getNotificationConfig();
|
||||
if (!config.telegramEnabled) return;
|
||||
const token = (config.telegramBotToken || "").trim();
|
||||
if (!token) return;
|
||||
|
||||
const recipients = getPerUserRecipients(record.clientId);
|
||||
const promises = recipients
|
||||
.filter((r) => r.canAccessClient && r.chatId)
|
||||
.map((r) => sendTelegramToChat(token, r.chatId, record, screenshot));
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
export async function deliverNotificationWithScreenshot(
|
||||
record: NotificationRecord,
|
||||
getNotificationConfig: () => any,
|
||||
getPerUserRecipients?: (clientId: string) => PerUserTelegramRecipient[],
|
||||
): Promise<void> {
|
||||
const screenshot = await waitForNotificationScreenshot(record.id);
|
||||
await Promise.allSettled([
|
||||
const deliveries: Promise<void>[] = [
|
||||
postNotificationWebhook(record, getNotificationConfig, screenshot),
|
||||
postTelegramNotification(record, getNotificationConfig, screenshot),
|
||||
]);
|
||||
];
|
||||
|
||||
if (getPerUserRecipients) {
|
||||
deliveries.push(
|
||||
postPerUserTelegramNotifications(record, getNotificationConfig, getPerUserRecipients, screenshot),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(deliveries);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ export async function handleAuthRoutes(
|
||||
role: user.role,
|
||||
userId: user.userId,
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteBuild, getAllBuilds, getBuild } from "../../db";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { logger } from "../../logger";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { resolveRuntimeRoot } from "../runtime-paths";
|
||||
|
||||
type RequestIpProvider = {
|
||||
@@ -35,7 +36,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/build/start") {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
@@ -131,22 +132,40 @@ export async function handleBuildRoutes(
|
||||
persistenceMethod: safePersistenceMethod,
|
||||
hideConsole: !!hideConsole,
|
||||
noPrinting: safeNoPrinting,
|
||||
builtByUserId: user.userId,
|
||||
});
|
||||
|
||||
return Response.json({ buildId });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/build/list") {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const builds = getAllBuilds();
|
||||
return Response.json({ builds });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/build\/(.+)\/delete$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const buildId = decodeURIComponent(url.pathname.split("/")[3]);
|
||||
|
||||
const build = getBuild(buildId);
|
||||
if (build?.files) {
|
||||
const rootDir = resolveRuntimeRoot();
|
||||
const outDir = path.join(rootDir, "dist-clients");
|
||||
for (const file of build.files) {
|
||||
try {
|
||||
const filePath = path.join(outDir, file.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`[build:delete] Removed file: ${filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[build:delete] Failed to remove file ${file.filename}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildManager.deleteBuildStream(buildId);
|
||||
deleteBuild(buildId);
|
||||
@@ -161,11 +180,12 @@ export async function handleBuildRoutes(
|
||||
success: true,
|
||||
});
|
||||
|
||||
logger.info(`[build:delete] Build ${buildId.substring(0, 8)} deleted by ${user.username}`);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/stream$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
@@ -219,7 +239,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/(.+)\/info$/)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const buildId = url.pathname.split("/")[3];
|
||||
const build = buildManager.getBuildStream(buildId);
|
||||
@@ -248,7 +268,7 @@ export async function handleBuildRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/build\/download\//)) {
|
||||
requirePermission(user, "clients:control");
|
||||
requirePermission(user, "clients:build");
|
||||
|
||||
const rawName = url.pathname.split("/api/build/download/")[1] || "";
|
||||
let fileName = rawName;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig } from "../../config";
|
||||
import { getClientMetricsSummary } from "../../db";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId } from "../../users";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
|
||||
type MiscRouteDeps = {
|
||||
@@ -66,6 +67,35 @@ export async function handleMiscRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/telegram") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const chatId = getUserTelegramChatId(user.userId);
|
||||
return Response.json({ telegramChatId: chatId || "" });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const chatId = typeof body?.telegramChatId === "string" ? body.telegramChatId.trim() : null;
|
||||
const result = setUserTelegramChatId(user.userId, chatId || null);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ success: true, telegramChatId: chatId || "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/security") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
|
||||
@@ -203,6 +203,10 @@ export async function handlePageRoutes(
|
||||
return serveLoginOrUnauthorized(deps);
|
||||
}
|
||||
|
||||
if (user.role !== "admin" && user.role !== "operator") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
|
||||
if (maybeChange) return maybeChange;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
removeUserClientAccessRule,
|
||||
setUserClientAccessRule,
|
||||
setUserClientAccessScope,
|
||||
setUserCanBuild,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
@@ -242,6 +243,13 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (access === "allow") {
|
||||
const currentScope = getUserClientAccessScope(userId);
|
||||
if (currentScope === "none") {
|
||||
setUserClientAccessScope(userId, "allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
@@ -316,6 +324,34 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/can-build$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const targetUser = getUserById(userId);
|
||||
if (!targetUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canBuild = !!body?.canBuild;
|
||||
const result = setUserCanBuild(userId, canBuild);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `${canBuild ? "Granted" : "Revoked"} build permission for ${targetUser.username}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, canBuild });
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
|
||||
@@ -79,6 +79,7 @@ type WsLifecycleDeps = {
|
||||
notifyConsoleClosed: (clientId: string, reason: string) => void;
|
||||
clearPendingNotificationScreenshots: (clientId: string) => void;
|
||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
||||
};
|
||||
|
||||
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
|
||||
@@ -188,6 +189,11 @@ export function handleWebSocketMessage(
|
||||
details: JSON.stringify({ host: info.host, os: info.os, user: info.user }),
|
||||
});
|
||||
(ws as any).data.wasKnown = true;
|
||||
|
||||
const buildTag = typeof (payload as any).buildTag === "string" ? (payload as any).buildTag : "";
|
||||
if (buildTag) {
|
||||
deps.handleBuildTagConnection(info.id, buildTag);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canBuildClients,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserByUsername,
|
||||
hasPermission,
|
||||
setUserCanBuild,
|
||||
} from "./users";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function createTempUser(role: "viewer" | "operator" | "admin") {
|
||||
const username = `bp_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
|
||||
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserByUsername(username);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function cleanupCreatedUsers() {
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") {
|
||||
deleteUser(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("build permission (can_build)", () => {
|
||||
test("admin always has build permission regardless of can_build flag", async () => {
|
||||
try {
|
||||
const admin = await createTempUser("admin");
|
||||
expect(canBuildClients(admin.id, admin.role)).toBe(true);
|
||||
|
||||
setUserCanBuild(admin.id, false);
|
||||
// Admin role always returns true
|
||||
expect(canBuildClients(admin.id, "admin")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("operator defaults to can_build = 1", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
expect(canBuildClients(operator.id, operator.role)).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer defaults to can_build = 0", async () => {
|
||||
try {
|
||||
const viewer = await createTempUser("viewer");
|
||||
expect(canBuildClients(viewer.id, viewer.role)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("revoking build permission denies building for non-admin", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
expect(canBuildClients(operator.id, operator.role)).toBe(true);
|
||||
|
||||
const result = setUserCanBuild(operator.id, false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(canBuildClients(operator.id, operator.role)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("granting build permission re-enables building", async () => {
|
||||
try {
|
||||
const viewer = await createTempUser("viewer");
|
||||
|
||||
expect(canBuildClients(viewer.id, viewer.role)).toBe(false);
|
||||
|
||||
setUserCanBuild(viewer.id, true);
|
||||
expect(canBuildClients(viewer.id, viewer.role)).toBe(true);
|
||||
|
||||
setUserCanBuild(viewer.id, false);
|
||||
expect(canBuildClients(viewer.id, viewer.role)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("hasPermission for clients:build uses userId when provided", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
expect(hasPermission(operator.role, "clients:build", operator.id)).toBe(true);
|
||||
|
||||
setUserCanBuild(operator.id, false);
|
||||
expect(hasPermission(operator.role, "clients:build", operator.id)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("hasPermission for clients:build without userId falls back to role check", () => {
|
||||
expect(hasPermission("admin", "clients:build")).toBe(true);
|
||||
expect(hasPermission("operator", "clients:build")).toBe(true);
|
||||
expect(hasPermission("viewer", "clients:build")).toBe(false);
|
||||
});
|
||||
|
||||
test("other permissions are unaffected by can_build", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
setUserCanBuild(operator.id, false);
|
||||
|
||||
expect(hasPermission(operator.role, "clients:control", operator.id)).toBe(true);
|
||||
expect(hasPermission(operator.role, "users:manage", operator.id)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserByUsername,
|
||||
getUserTelegramChatId,
|
||||
getUsersWithTelegramChatId,
|
||||
setUserTelegramChatId,
|
||||
setUserClientAccessScope,
|
||||
setUserClientAccessRule,
|
||||
canUserAccessClient,
|
||||
} from "./users";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function createTempUser(role: "viewer" | "operator" | "admin") {
|
||||
const username = `tg_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
|
||||
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserByUsername(username);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function cleanupCreatedUsers() {
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") {
|
||||
deleteUser(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("per-user Telegram settings", () => {
|
||||
test("new user has no telegram chat ID", async () => {
|
||||
try {
|
||||
const user = await createTempUser("operator");
|
||||
expect(getUserTelegramChatId(user.id)).toBeNull();
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserTelegramChatId stores and retrieves chat ID", async () => {
|
||||
try {
|
||||
const user = await createTempUser("operator");
|
||||
|
||||
const result = setUserTelegramChatId(user.id, "123456789");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(getUserTelegramChatId(user.id)).toBe("123456789");
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserTelegramChatId with null clears the chat ID", async () => {
|
||||
try {
|
||||
const user = await createTempUser("viewer");
|
||||
|
||||
setUserTelegramChatId(user.id, "987654321");
|
||||
expect(getUserTelegramChatId(user.id)).toBe("987654321");
|
||||
|
||||
setUserTelegramChatId(user.id, null);
|
||||
expect(getUserTelegramChatId(user.id)).toBeNull();
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsersWithTelegramChatId returns only users with configured chat IDs", async () => {
|
||||
try {
|
||||
const user1 = await createTempUser("operator");
|
||||
const user2 = await createTempUser("viewer");
|
||||
const user3 = await createTempUser("operator");
|
||||
|
||||
setUserTelegramChatId(user1.id, "chat_111");
|
||||
// user2 has no chat ID
|
||||
setUserTelegramChatId(user3.id, "chat_333");
|
||||
|
||||
const withChatId = getUsersWithTelegramChatId();
|
||||
const ourUsers = withChatId.filter((u) => [user1.id, user2.id, user3.id].includes(u.id));
|
||||
|
||||
expect(ourUsers.length).toBe(2);
|
||||
expect(ourUsers.some((u) => u.id === user1.id && u.telegram_chat_id === "chat_111")).toBe(true);
|
||||
expect(ourUsers.some((u) => u.id === user3.id && u.telegram_chat_id === "chat_333")).toBe(true);
|
||||
expect(ourUsers.some((u) => u.id === user2.id)).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsersWithTelegramChatId returns role and client_scope", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
setUserTelegramChatId(operator.id, "chat_op");
|
||||
setUserClientAccessScope(operator.id, "allowlist");
|
||||
|
||||
const users = getUsersWithTelegramChatId();
|
||||
const found = users.find((u) => u.id === operator.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.role).toBe("operator");
|
||||
expect(found!.client_scope).toBe("allowlist");
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("per-user notification filtering respects client access", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
setUserTelegramChatId(operator.id, "chat_filter");
|
||||
setUserClientAccessScope(operator.id, "allowlist");
|
||||
setUserClientAccessRule(operator.id, "allowed-client", "allow");
|
||||
|
||||
// Operator should see allowed-client but not other-client
|
||||
expect(canUserAccessClient(operator.id, operator.role, "allowed-client")).toBe(true);
|
||||
expect(canUserAccessClient(operator.id, operator.role, "other-client")).toBe(false);
|
||||
|
||||
// This simulates what the notification delivery does:
|
||||
// get users with chat IDs, then filter by canUserAccessClient
|
||||
const recipients = getUsersWithTelegramChatId();
|
||||
const opRecipient = recipients.find((r) => r.id === operator.id);
|
||||
expect(opRecipient).not.toBeNull();
|
||||
|
||||
const canAccessAllowed = canUserAccessClient(operator.id, operator.role, "allowed-client");
|
||||
const canAccessOther = canUserAccessClient(operator.id, operator.role, "other-client");
|
||||
|
||||
expect(canAccessAllowed).toBe(true);
|
||||
expect(canAccessOther).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("admin with telegram chat ID can access all clients", async () => {
|
||||
try {
|
||||
const admin = await createTempUser("admin");
|
||||
setUserTelegramChatId(admin.id, "chat_admin");
|
||||
|
||||
expect(canUserAccessClient(admin.id, admin.role, "any-client-1")).toBe(true);
|
||||
expect(canUserAccessClient(admin.id, admin.role, "any-client-2")).toBe(true);
|
||||
|
||||
const recipients = getUsersWithTelegramChatId();
|
||||
const adminRecipient = recipients.find((r) => r.id === admin.id);
|
||||
expect(adminRecipient).not.toBeNull();
|
||||
expect(adminRecipient!.telegram_chat_id).toBe("chat_admin");
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export interface User {
|
||||
created_by: string | null;
|
||||
must_change_password: number;
|
||||
client_scope: ClientAccessScope;
|
||||
can_build: number;
|
||||
telegram_chat_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -32,6 +34,8 @@ export interface UserInfo {
|
||||
last_login: number | null;
|
||||
created_by: string | null;
|
||||
client_scope: ClientAccessScope;
|
||||
can_build: number;
|
||||
telegram_chat_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserClientAccessRule {
|
||||
@@ -97,6 +101,34 @@ try {
|
||||
logger.error("[users] Failed to normalize admin client_scope:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE users ADD COLUMN can_build INTEGER NOT NULL DEFAULT 0`,
|
||||
);
|
||||
logger.info("[users] Added can_build column to existing database");
|
||||
|
||||
try {
|
||||
db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`);
|
||||
} catch (err: any) {
|
||||
logger.error("[users] Failed to backfill admin/operator can_build:", err);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes("duplicate column name")) {
|
||||
logger.error("[users] Migration error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE users ADD COLUMN telegram_chat_id TEXT DEFAULT NULL`,
|
||||
);
|
||||
logger.info("[users] Added telegram_chat_id column to existing database");
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes("duplicate column name")) {
|
||||
logger.error("[users] Migration error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
|
||||
count: number;
|
||||
};
|
||||
@@ -112,8 +144,8 @@ if (userCount.count === 0) {
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all");
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, can_build) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all", 1);
|
||||
|
||||
const createdUser = db
|
||||
.prepare("SELECT * FROM users WHERE username = ?")
|
||||
@@ -149,7 +181,7 @@ export function getUserByUsername(username: string): User | null {
|
||||
export function listUsers(): UserInfo[] {
|
||||
const users = db
|
||||
.prepare(
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope FROM users ORDER BY created_at DESC",
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, can_build, telegram_chat_id FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
.all() as UserInfo[];
|
||||
return users;
|
||||
@@ -328,9 +360,9 @@ export async function createUser(
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none");
|
||||
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0);
|
||||
|
||||
return { success: true, userId: result.lastInsertRowid as number };
|
||||
} catch (err: any) {
|
||||
@@ -440,11 +472,17 @@ export function canViewClients(role: UserRole): boolean {
|
||||
return role === "admin";
|
||||
}
|
||||
|
||||
export function canBuildClients(userId: number, role: UserRole): boolean {
|
||||
if (role === "admin") return true;
|
||||
const user = getUserById(userId);
|
||||
return user ? user.can_build === 1 : false;
|
||||
}
|
||||
|
||||
export function canViewAuditLogs(role: UserRole): boolean {
|
||||
return role === "admin";
|
||||
}
|
||||
|
||||
export function hasPermission(role: UserRole, permission: string): boolean {
|
||||
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
|
||||
switch (permission) {
|
||||
case "users:manage":
|
||||
return canManageUsers(role);
|
||||
@@ -452,9 +490,53 @@ export function hasPermission(role: UserRole, permission: string): boolean {
|
||||
return canControlClients(role);
|
||||
case "clients:view":
|
||||
return canViewClients(role);
|
||||
case "clients:build":
|
||||
if (userId !== undefined) return canBuildClients(userId, role);
|
||||
return role === "admin" || role === "operator";
|
||||
case "audit:view":
|
||||
return canViewAuditLogs(role);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserCanBuild(
|
||||
userId: number,
|
||||
canBuild: boolean,
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET can_build = ? WHERE id = ?").run(canBuild ? 1 : 0, userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserCanBuild error:", err);
|
||||
return { success: false, error: err.message || "Failed to update build permission" };
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserTelegramChatId(
|
||||
userId: number,
|
||||
chatId: string | null,
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET telegram_chat_id = ? WHERE id = ?").run(chatId, userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserTelegramChatId error:", err);
|
||||
return { success: false, error: err.message || "Failed to update Telegram chat ID" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserTelegramChatId(userId: number): string | null {
|
||||
const row = db
|
||||
.prepare("SELECT telegram_chat_id FROM users WHERE id = ?")
|
||||
.get(userId) as { telegram_chat_id?: string | null } | undefined;
|
||||
return row?.telegram_chat_id || null;
|
||||
}
|
||||
|
||||
export function getUsersWithTelegramChatId(): Array<{ id: number; username: string; role: UserRole; client_scope: ClientAccessScope; telegram_chat_id: string }> {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT id, username, role, client_scope, telegram_chat_id FROM users WHERE telegram_chat_id IS NOT NULL AND telegram_chat_id != ''",
|
||||
)
|
||||
.all() as any[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user