Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions backend/agent-socket-handlers/docker-socket-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,51 @@ import { DockgeServer } from "../dockge-server";
import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server";
import { Stack } from "../stack";
import { AgentSocket } from "../../common/agent-socket";
import { R } from "redbean-node";

export class DockerSocketHandler extends AgentSocketHandler {
create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {
// Do not call super.create()

/**
* Check if user has access to a stack
* Admin users have access to all stacks
* Non-admin users only have access to stacks assigned to their groups
*/
const checkStackAccess = async (stackName: string) => {
const user = await R.findOne("user", " id = ? AND active = 1 ", [socket.userID]);

if (!user) {
throw new ValidationError("User not found");
}

const isAdmin = user.is_admin === true || user.is_admin === 1;

if (isAdmin) {
return true;
}

// Check if user has access through groups
const hasAccess = await R.getRow(`
SELECT COUNT(*) as count FROM stack_group sg
INNER JOIN user_group ug ON sg.group_id = ug.group_id
WHERE ug.user_id = ? AND sg.stack_name = ?
`, [socket.userID, stackName]);

if (!hasAccess || hasAccess.count === 0) {
throw new ValidationError("You do not have permission to access this stack");
}

return true;
};

agentSocket.on("deployStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
// Check permission for existing stacks (new stacks are allowed for all logged-in users)
if (!isAdd && typeof(name) === "string") {
await checkStackAccess(name);
}
const stack = await this.saveStack(server, name, composeYAML, composeENV, isAdd);
await stack.deploy(socket);
server.sendStackList();
Expand All @@ -28,6 +65,10 @@ export class DockerSocketHandler extends AgentSocketHandler {
agentSocket.on("saveStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
// Check permission for existing stacks
if (!isAdd && typeof(name) === "string") {
await checkStackAccess(name);
}
await this.saveStack(server, name, composeYAML, composeENV, isAdd);
callbackResult({
ok: true,
Expand All @@ -46,6 +87,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
if (typeof(name) !== "string") {
throw new ValidationError("Name must be a string");
}
await checkStackAccess(name);
const stack = await Stack.getStack(server, name);

try {
Expand Down Expand Up @@ -75,6 +117,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);

if (stack.isManagedByDockge) {
Expand Down Expand Up @@ -114,6 +157,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.start(socket);
callbackResult({
Expand All @@ -139,6 +183,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.stop(socket);
callbackResult({
Expand All @@ -161,6 +206,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.restart(socket);
callbackResult({
Expand All @@ -183,6 +229,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.update(socket);
callbackResult({
Expand All @@ -205,6 +252,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.down(socket);
callbackResult({
Expand All @@ -227,6 +275,7 @@ export class DockerSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName, true);
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
callbackResult({
Expand Down
33 changes: 33 additions & 0 deletions backend/agent-socket-handlers/terminal-socket-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,41 @@ import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
import { Stack } from "../stack";
import { AgentSocketHandler } from "../agent-socket-handler";
import { AgentSocket } from "../../common/agent-socket";
import { R } from "redbean-node";

export class TerminalSocketHandler extends AgentSocketHandler {
create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {

/**
* Check if user has access to a stack
*/
const checkStackAccess = async (stackName: string) => {
const user = await R.findOne("user", " id = ? AND active = 1 ", [socket.userID]);

if (!user) {
throw new ValidationError("User not found");
}

const isAdmin = user.is_admin === true || user.is_admin === 1;

if (isAdmin) {
return true;
}

// Check if user has access through groups
const hasAccess = await R.getRow(`
SELECT COUNT(*) as count FROM stack_group sg
INNER JOIN user_group ug ON sg.group_id = ug.group_id
WHERE ug.user_id = ? AND sg.stack_name = ?
`, [socket.userID, stackName]);

if (!hasAccess || hasAccess.count === 0) {
throw new ValidationError("You do not have permission to access this stack");
}

return true;
};

agentSocket.on("terminalInput", async (terminalName : unknown, cmd : unknown, callback) => {
try {
checkLogin(socket);
Expand Down Expand Up @@ -103,6 +134,7 @@ export class TerminalSocketHandler extends AgentSocketHandler {
log.debug("interactiveTerminal", "Stack name: " + stackName);
log.debug("interactiveTerminal", "Service name: " + serviceName);

await checkStackAccess(stackName);
// Get stack
const stack = await Stack.getStack(server, stackName);
stack.joinContainerTerminal(socket, serviceName, shell);
Expand Down Expand Up @@ -154,6 +186,7 @@ export class TerminalSocketHandler extends AgentSocketHandler {
throw new ValidationError("Stack name must be a string.");
}

await checkStackAccess(stackName);
const stack = await Stack.getStack(server, stackName);
await stack.leaveCombinedTerminal(socket);

Expand Down
37 changes: 36 additions & 1 deletion backend/dockge-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,22 @@ export class DockgeServer {
isContainer = (process.env.DOCKGE_IS_CONTAINER === "1");
}

// Get user admin status if logged in
let isAdmin = false;
const dockgeSocket = socket as DockgeSocket;
if (dockgeSocket.userID) {
const user = await R.findOne("user", " id = ? AND active = 1 ", [dockgeSocket.userID]);
if (user) {
isAdmin = user.is_admin === true || user.is_admin === 1;
}
}

socket.emit("info", {
version: versionProperty,
latestVersion: latestVersionProperty,
isContainer,
primaryHostname: await Settings.get("primaryHostname"),
isAdmin: isAdmin,
//serverTimezone: await this.getTimezone(),
//serverTimezoneOffset: this.getTimezoneOffset(),
});
Expand Down Expand Up @@ -601,10 +612,34 @@ export class DockgeServer {
stackList = await Stack.getStackList(this, useCache);
}

// Get user to check permissions
const user = await R.findOne("user", " id = ? AND active = 1 ", [dockgeSocket.userID]);

if (!user) {
continue;
}

const isAdmin = user.is_admin === true || user.is_admin === 1;

let map : Map<string, object> = new Map();

// Filter stacks based on user permissions
for (let [ stackName, stack ] of stackList) {
map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint));
// Admin sees all stacks
if (isAdmin) {
map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint));
} else {
// Non-admin users only see stacks assigned to their groups
const hasAccess = await R.getRow(`
SELECT COUNT(*) as count FROM stack_group sg
INNER JOIN user_group ug ON sg.group_id = ug.group_id
WHERE ug.user_id = ? AND sg.stack_name = ?
`, [dockgeSocket.userID, stackName]);

if (hasAccess && hasAccess.count > 0) {
map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint));
}
}
}

log.debug("server", "Send stack list to user: " + dockgeSocket.id + " (" + dockgeSocket.endpoint + ")");
Expand Down
14 changes: 14 additions & 0 deletions backend/migrations/2025-11-26-0000-add-is-admin-to-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
// Add is_admin column to user table
return knex.schema.alterTable("user", (table) => {
table.boolean("is_admin").notNullable().defaultTo(false);
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable("user", (table) => {
table.dropColumn("is_admin");
});
}
16 changes: 16 additions & 0 deletions backend/migrations/2025-11-26-0001-create-group-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
// Create the group table
return knex.schema.createTable("group", (table) => {
table.increments("id");
table.string("name", 255).notNullable().unique();
table.text("description");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable("group");
}
22 changes: 22 additions & 0 deletions backend/migrations/2025-11-26-0002-create-user-group-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
// Create the user_group association table
return knex.schema.createTable("user_group", (table) => {
table.increments("id");
table.integer("user_id").unsigned().notNullable();
table.integer("group_id").unsigned().notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());

// Foreign keys
table.foreign("user_id").references("id").inTable("user").onDelete("CASCADE");
table.foreign("group_id").references("id").inTable("group").onDelete("CASCADE");

// Ensure unique user-group pairs
table.unique(["user_id", "group_id"]);
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable("user_group");
}
24 changes: 24 additions & 0 deletions backend/migrations/2025-11-26-0003-create-stack-group-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
// Create the stack_group association table
return knex.schema.createTable("stack_group", (table) => {
table.increments("id");
table.string("stack_name", 255).notNullable();
table.integer("group_id").unsigned().notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());

// Foreign key to group
table.foreign("group_id").references("id").inTable("group").onDelete("CASCADE");

// Ensure unique stack-group pairs
table.unique(["stack_name", "group_id"]);

// Add index for faster lookups
table.index("stack_name");
});
}

export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable("stack_group");
}
13 changes: 13 additions & 0 deletions backend/migrations/2025-11-26-0004-set-first-user-as-admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
// Set the first user as admin (if exists)
const firstUser = await knex("user").orderBy("id", "asc").first();
if (firstUser) {
await knex("user").where("id", firstUser.id).update({ is_admin: true });
}
}

export async function down(knex: Knex): Promise<void> {
// No need to revert this change
}
Loading
Loading