mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: folder logins for shared hosts, read only plugin sync, full desktop builds
This commit is contained in:
1 parent
b32203d19b
commit
a0e13f9eeb
33 files changed
+15245
-14
No files matched your search
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
credential_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
|
||||
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = mysqlTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = mysqlTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -490,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = pgTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = pgTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -480,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = sqliteTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: text("folder").notNull(),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = sqliteTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||
return new SharedFolderAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"shared_folder_auth_override_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
import { sharedFolderAuthOverrides } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { deleteReturning, upsert } from "./returning.js";
|
||||
|
||||
/** "A / B / C" and the folders above it, closest first. */
|
||||
export function folderAndParents(folder: string | null | undefined): string[] {
|
||||
const parts = (folder ?? "")
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
out.push(parts.slice(0, i).join(" / "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SharedFolderAuthOverrideRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findCredentialId(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.credentialId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login set on the host's folder or the closest folder above it, for
|
||||
* one protocol. Null when none is set.
|
||||
*/
|
||||
async findClosest(
|
||||
userId: string,
|
||||
hostFolder: string | null | undefined,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<{ folder: string; credentialId: number } | null> {
|
||||
const folders = folderAndParents(hostFolder);
|
||||
if (folders.length === 0) return null;
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
folder: sharedFolderAuthOverrides.folder,
|
||||
credentialId: sharedFolderAuthOverrides.credentialId,
|
||||
})
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
inArray(sharedFolderAuthOverrides.folder, folders),
|
||||
),
|
||||
);
|
||||
for (const folder of folders) {
|
||||
const row = rows.find((candidate) => candidate.folder === folder);
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
credentialId: number,
|
||||
): Promise<void> {
|
||||
await upsert(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
{ userId, folder, protocol, credentialId },
|
||||
{
|
||||
target: [
|
||||
sharedFolderAuthOverrides.userId,
|
||||
sharedFolderAuthOverrides.folder,
|
||||
sharedFolderAuthOverrides.protocol,
|
||||
],
|
||||
set: { credentialId, updatedAt: new Date().toISOString() },
|
||||
},
|
||||
);
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async clearCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<boolean> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
);
|
||||
if (rows.length > 0) await this.onWrite?.();
|
||||
return rows.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,10 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -2225,4 +2227,157 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** A folder path as hosts store it ("Parent / Child"), or null. */
|
||||
function folderParam(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const folder = value
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
return folder && folder.length <= 255 ? folder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* get:
|
||||
* summary: Get the current user's personal login for shared hosts in a folder
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: folder
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The folder, protocol and credential id, or null when none is set.
|
||||
* 400:
|
||||
* description: Invalid folder or protocol.
|
||||
*/
|
||||
router.get(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.query.folder);
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
const credentialId =
|
||||
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
|
||||
userId,
|
||||
folder,
|
||||
protocol,
|
||||
);
|
||||
res.json({ folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get folder login", error);
|
||||
res.status(500).json({ error: "Failed to fetch credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* put:
|
||||
* summary: Set the current user's personal login for shared hosts in a folder
|
||||
* description: >
|
||||
* Used for every host shared with the user in this folder and its
|
||||
* subfolders that has no personal login of its own. credentialId null
|
||||
* clears it.
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [folder, credentialId]
|
||||
* properties:
|
||||
* folder: { type: string }
|
||||
* credentialId: { type: integer, nullable: true }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid folder, protocol or credential id.
|
||||
* 404:
|
||||
* description: The credential is not one the user can use.
|
||||
*/
|
||||
router.put(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.body?.folder);
|
||||
const credentialId = req.body?.credentialId ?? null;
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
if (
|
||||
credentialId !== null &&
|
||||
(!Number.isInteger(credentialId) || credentialId <= 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "credentialId must be a positive integer or null",
|
||||
});
|
||||
}
|
||||
|
||||
const repository = createCurrentSharedFolderAuthOverrideRepository();
|
||||
if (credentialId === null) {
|
||||
await repository.clearCredential(userId, folder, protocol);
|
||||
} else {
|
||||
if (!(await findUsableCredential(credentialId, userId))) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
await repository.setCredential(userId, folder, protocol, credentialId);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action:
|
||||
credentialId === null
|
||||
? "clear_shared_folder_auth_override"
|
||||
: "set_shared_folder_auth_override",
|
||||
resourceType: "folder",
|
||||
resourceName: folder,
|
||||
details: JSON.stringify({ protocol, credentialId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
}).catch(() => undefined);
|
||||
|
||||
res.json({ success: true, folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set folder login", error);
|
||||
res.status(500).json({ error: "Failed to update credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
|
||||
...recipientHost,
|
||||
authOverrides: {
|
||||
...authOverrides,
|
||||
ssh: {
|
||||
credentialId: resolution.credentialId,
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
},
|
||||
ssh: resolution.folder
|
||||
? {
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
folder: resolution.folder,
|
||||
folderCredentialId: resolution.credentialId,
|
||||
}
|
||||
: {
|
||||
credentialId: resolution.credentialId,
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
},
|
||||
},
|
||||
authType:
|
||||
credential.key || credential.privateKey
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
SyncRow,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
/** Core-only extras a plugin registration cannot set. */
|
||||
/** Extras core sets for its own entities. A plugin may set the first four. */
|
||||
export interface CoreSyncEntityOptions {
|
||||
/** Server-to-desktop only. A desktop never pushes it. */
|
||||
readOnly?: boolean;
|
||||
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
|
||||
encryptedFields: readonly string[];
|
||||
}
|
||||
|
||||
/** What a plugin registration may carry. Anything else is dropped. */
|
||||
const PLUGIN_KEYS = [
|
||||
"type",
|
||||
"table",
|
||||
"userColumn",
|
||||
"encryptedFields",
|
||||
"references",
|
||||
"order",
|
||||
"readOnlyFields",
|
||||
"singleton",
|
||||
"answersTo",
|
||||
"shouldSync",
|
||||
"permissions",
|
||||
"serialize",
|
||||
"deserialize",
|
||||
"afterWrite",
|
||||
"remove",
|
||||
"readOnly",
|
||||
"load",
|
||||
"write",
|
||||
"erase",
|
||||
] as const;
|
||||
|
||||
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of PLUGIN_KEYS) {
|
||||
if (entity[key] !== undefined) picked[key] = entity[key];
|
||||
}
|
||||
const out = picked as unknown as SyncEntityRegistration;
|
||||
if (out.load || out.write || out.erase) {
|
||||
if (!out.load || !out.write || !out.erase) {
|
||||
throw new Error(
|
||||
`Sync entity "${entity.type}" needs load, write and erase together`,
|
||||
);
|
||||
}
|
||||
// A plugin only ever syncs its own rows both ways; rows it builds for
|
||||
// a user come down as read only copies.
|
||||
if (out.readOnly !== true) {
|
||||
throw new Error(
|
||||
`Sync entity "${entity.type}" with load must be readOnly`,
|
||||
);
|
||||
}
|
||||
} else if (!out.table) {
|
||||
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const entities = new Map<string, RegisteredSyncEntity>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -81,7 +129,7 @@ export function registerEntity(
|
||||
}
|
||||
|
||||
const registered: RegisteredSyncEntity = {
|
||||
...entity,
|
||||
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
|
||||
...(owner === CORE_OWNER ? core : {}),
|
||||
owner,
|
||||
order: entity.order ?? 100,
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
folderAndParents,
|
||||
SharedFolderAuthOverrideRepository,
|
||||
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
|
||||
describe("folderAndParents", () => {
|
||||
it("lists the folder, then each folder above it", () => {
|
||||
expect(folderAndParents("Prod / Web / EU")).toEqual([
|
||||
"Prod / Web / EU",
|
||||
"Prod / Web",
|
||||
"Prod",
|
||||
]);
|
||||
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
|
||||
expect(folderAndParents("")).toEqual([]);
|
||||
expect(folderAndParents(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SharedFolderAuthOverrideRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
async function createRepository(onWrite?: () => void) {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||
VALUES (7, 'recipient', 'cred-seven', 'password'),
|
||||
(8, 'recipient', 'cred-eight', 'password');
|
||||
`);
|
||||
return new SharedFolderAuthOverrideRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("sets, updates and clears a login per user, folder and protocol", async () => {
|
||||
let writes = 0;
|
||||
const repository = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 8);
|
||||
await repository.setCredential("recipient", "Prod", "rdp", 7);
|
||||
await expect(
|
||||
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(8);
|
||||
await expect(
|
||||
repository.findCredentialId("other", "Prod", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(false);
|
||||
expect(writes).toBe(4);
|
||||
});
|
||||
|
||||
it("finds the login of the closest folder above a host", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
|
||||
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
|
||||
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod / DB", "ssh"),
|
||||
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Staging", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod", "rdp"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findClosest("recipient", null, "ssh"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("drops the login when its credential is deleted", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
|
||||
await expect(
|
||||
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ const state = vi.hoisted(() => ({
|
||||
credentialUsable: true,
|
||||
credentialId: 7 as number | null,
|
||||
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
||||
folderWrites: [] as Array<{
|
||||
folder: string;
|
||||
protocol: string;
|
||||
credentialId: number | null;
|
||||
}>,
|
||||
auditCalls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
@@ -97,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => 7,
|
||||
setCredential: async (
|
||||
_userId: string,
|
||||
folder: string,
|
||||
protocol: string,
|
||||
credentialId: number,
|
||||
) => {
|
||||
state.folderWrites.push({ folder, protocol, credentialId });
|
||||
},
|
||||
clearCredential: async (
|
||||
_userId: string,
|
||||
folder: string,
|
||||
protocol: string,
|
||||
) => {
|
||||
state.folderWrites.push({ folder, protocol, credentialId: null });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ id: "recipient", username: "recipient" }),
|
||||
}),
|
||||
@@ -109,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
getAuditUsername: async (userId: string) => userId,
|
||||
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
||||
state.auditCalls.push(entry);
|
||||
}),
|
||||
@@ -134,6 +159,8 @@ describe("shared host authentication override routes", () => {
|
||||
method: "get" | "put",
|
||||
body: Record<string, unknown> = {},
|
||||
protocol = "ssh",
|
||||
path = "/host-access/:hostId/auth/:protocol",
|
||||
query: Record<string, unknown> = {},
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const routeLayer = (
|
||||
router as unknown as {
|
||||
@@ -152,15 +179,14 @@ describe("shared host authentication override routes", () => {
|
||||
}>;
|
||||
}
|
||||
).stack.find(
|
||||
(layer) =>
|
||||
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
|
||||
layer.route.methods[method],
|
||||
(layer) => layer.route?.path === path && layer.route.methods[method],
|
||||
);
|
||||
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
||||
|
||||
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
||||
const req = {
|
||||
params: { hostId: "42", protocol },
|
||||
query,
|
||||
body,
|
||||
headers: {},
|
||||
ip: "127.0.0.1",
|
||||
@@ -210,9 +236,103 @@ describe("shared host authentication override routes", () => {
|
||||
state.credentialUsable = true;
|
||||
state.credentialId = 7;
|
||||
state.writes = [];
|
||||
state.folderWrites = [];
|
||||
state.auditCalls = [];
|
||||
});
|
||||
|
||||
describe("folder logins", () => {
|
||||
const FOLDER = "/folder-auth/:protocol";
|
||||
|
||||
it("reads the login for a folder", async () => {
|
||||
const response = await invoke("get", {}, "ssh", FOLDER, {
|
||||
folder: " Prod / Web ",
|
||||
});
|
||||
expect(response).toEqual({
|
||||
status: 200,
|
||||
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and clears the login and audits both", async () => {
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 8 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: null },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(state.folderWrites).toEqual([
|
||||
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
|
||||
{ folder: "Prod", protocol: "ssh", credentialId: null },
|
||||
]);
|
||||
expect(state.auditCalls.map((call) => call.action)).toEqual([
|
||||
"set_shared_folder_auth_override",
|
||||
"clear_shared_folder_auth_override",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a bad folder, protocol or credential", async () => {
|
||||
expect(
|
||||
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "x".repeat(300), credentialId: 8 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 8 },
|
||||
"smtp",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: -1 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
state.credentialUsable = false;
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 99 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect(state.folderWrites).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the current override for a role-derived shared recipient", async () => {
|
||||
const response = await invoke("get");
|
||||
expect(response).toEqual({
|
||||
|
||||
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
|
||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||
expect(getEntity("thing")?.load).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a plugin sync rows it builds as read only copies", () => {
|
||||
const load = async () => [];
|
||||
registerEntity("demo", {
|
||||
type: "sharedIcons",
|
||||
table: null,
|
||||
readOnly: true,
|
||||
load,
|
||||
write: async () => undefined,
|
||||
erase: async () => undefined,
|
||||
});
|
||||
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
|
||||
});
|
||||
|
||||
it("refuses a plugin entity that loads rows but can be pushed", () => {
|
||||
expect(() =>
|
||||
registerEntity("demo", {
|
||||
type: "pushable",
|
||||
table: null,
|
||||
load: async () => [],
|
||||
write: async () => undefined,
|
||||
erase: async () => undefined,
|
||||
}),
|
||||
).toThrow(/readOnly/);
|
||||
expect(() =>
|
||||
registerEntity("demo", {
|
||||
type: "halfDone",
|
||||
table: null,
|
||||
readOnly: true,
|
||||
load: async () => [],
|
||||
}),
|
||||
).toThrow(/load, write and erase/);
|
||||
expect(() =>
|
||||
registerEntity("demo", { type: "nothing", table: null }),
|
||||
).toThrow(/table or load/);
|
||||
});
|
||||
|
||||
it("drops core only keys a plugin puts on its registration", () => {
|
||||
registerEntity("demo", {
|
||||
type: "sneaky",
|
||||
table: {},
|
||||
covers: () => true,
|
||||
canReference: async () => true,
|
||||
hashIgnore: ["x"],
|
||||
} as never);
|
||||
const entity = getEntity("sneaky")!;
|
||||
expect(entity.covers).toBeUndefined();
|
||||
expect(entity.canReference).toBeUndefined();
|
||||
expect(entity.hashIgnore).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("core sync entities", () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hostOverride: null as number | null,
|
||||
folderOverride: null as { folder: string; credentialId: number } | null,
|
||||
ownerSecret: null as Record<string, unknown> | null,
|
||||
usable: new Set<number>(),
|
||||
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
|
||||
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number) =>
|
||||
state.usable.has(id) ? { id, username: `user-${id}` } : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async () => "owner",
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findActiveHostAccess: async () => null,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.hostOverride,
|
||||
}),
|
||||
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
|
||||
state.folderLookups.push({ folder, protocol });
|
||||
return state.folderOverride;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
getSecretForUser: async () => state.ownerSecret,
|
||||
snapshotForUser: async () => undefined,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { resolveRecipientSharedHostAuthentication } =
|
||||
await import("../../utils/shared-host-auth-resolver.js");
|
||||
|
||||
const host = (extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
folder: "Prod / Web",
|
||||
authType: "credential",
|
||||
credentialId: 99,
|
||||
shareSshAuth: false,
|
||||
...extra,
|
||||
}) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
state.hostOverride = null;
|
||||
state.folderOverride = null;
|
||||
state.ownerSecret = null;
|
||||
state.usable = new Set();
|
||||
state.folderLookups = [];
|
||||
});
|
||||
|
||||
describe("folder logins for shared hosts", () => {
|
||||
it("uses the folder's login when the host has none of its own", async () => {
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.usable.add(7);
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
source: "personal-override",
|
||||
credentialId: 7,
|
||||
folder: "Prod",
|
||||
});
|
||||
expect(state.folderLookups).toEqual([
|
||||
{ folder: "Prod / Web", protocol: "ssh" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets the host's own login win over the folder's", async () => {
|
||||
state.hostOverride = 5;
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.usable.add(5).add(7);
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({ credentialId: 5 });
|
||||
expect(result).not.toHaveProperty("folder");
|
||||
});
|
||||
|
||||
it("skips a folder login the user can no longer use", async () => {
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.ownerSecret = { authType: "password", password: "x" };
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host({ shareSshAuth: true }),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({ source: "owner-shared" });
|
||||
});
|
||||
|
||||
it("asks for a login when nothing is set", async () => {
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toEqual({ source: "required" });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type {
|
||||
@@ -23,6 +24,8 @@ export type RecipientSharedHostAuthResolution =
|
||||
source: "personal-override";
|
||||
credentialId: number;
|
||||
credential: HostResolutionCredentialRecord;
|
||||
/** Set when the login comes from the folder, not the host itself. */
|
||||
folder?: string;
|
||||
}
|
||||
| {
|
||||
source: "owner-shared";
|
||||
@@ -54,8 +57,8 @@ function isOwnerAuthShared(
|
||||
|
||||
/**
|
||||
* Applies the shared-host authentication precedence independently from any
|
||||
* transport: recipient override, shared owner auth, secretless auth, then
|
||||
* "required".
|
||||
* transport: recipient override on the host, then on its folder, shared
|
||||
* owner auth, secretless auth, then "required".
|
||||
*/
|
||||
export async function resolveRecipientSharedHostAuthentication(
|
||||
host: HostResolutionHostRecord,
|
||||
@@ -91,6 +94,31 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const folderOverride =
|
||||
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
|
||||
userId,
|
||||
host.folder,
|
||||
protocol,
|
||||
);
|
||||
if (folderOverride) {
|
||||
const credential = await findUsableCredential(
|
||||
folderOverride.credentialId,
|
||||
userId,
|
||||
);
|
||||
if (credential) {
|
||||
return {
|
||||
source: "personal-override",
|
||||
credentialId: folderOverride.credentialId,
|
||||
credential,
|
||||
folder: folderOverride.folder,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Same as a host override: an unreadable one counts as none.
|
||||
}
|
||||
|
||||
if (isOwnerAuthShared(host, protocol)) {
|
||||
if (protocol === "ssh" && host.authType === "agent") {
|
||||
return {
|
||||
|
||||
@@ -388,6 +388,54 @@ export async function getHostAuthOverride(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own login for hosts shared with them in a folder. On a desktop
|
||||
* linked to a server, pass a shared host from the folder so the call goes to
|
||||
* that server.
|
||||
*/
|
||||
export async function getFolderAuthOverride(
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
sharedCopySyncId?: string | null,
|
||||
): Promise<{
|
||||
folder: string;
|
||||
protocol: AuthOverrideProtocol;
|
||||
credentialId: number | null;
|
||||
}> {
|
||||
try {
|
||||
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
|
||||
params: { folder },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch folder authentication override");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFolderAuthOverride(
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
credentialId: number | null,
|
||||
sharedCopySyncId?: string | null,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
folder: string;
|
||||
protocol: AuthOverrideProtocol;
|
||||
credentialId: number | null;
|
||||
}> {
|
||||
try {
|
||||
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
|
||||
folder,
|
||||
credentialId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update folder authentication override");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setHostAuthOverride(
|
||||
hostId: number,
|
||||
protocol: AuthOverrideProtocol,
|
||||
|
||||
@@ -1317,6 +1317,8 @@ export {
|
||||
revokeHostAccess,
|
||||
getHostAuthOverride,
|
||||
setHostAuthOverride,
|
||||
getFolderAuthOverride,
|
||||
setFolderAuthOverride,
|
||||
getPermissionsCatalog,
|
||||
getSharedHosts,
|
||||
} from "@/api/rbac-api";
|
||||
|
||||
@@ -640,6 +640,7 @@ export {
|
||||
getExternalAutoProvision,
|
||||
getHostAccess,
|
||||
getHostAuthOverride,
|
||||
getFolderAuthOverride,
|
||||
getHostPassword,
|
||||
getHostPolicy,
|
||||
type HostPolicy,
|
||||
@@ -682,6 +683,7 @@ export {
|
||||
saveCredentialSidebarPreferences,
|
||||
saveHostSidebarPreferences,
|
||||
setHostAuthOverride,
|
||||
setFolderAuthOverride,
|
||||
shareFolder,
|
||||
shareHost,
|
||||
type SSHHostWithStatus,
|
||||
|
||||
Reference in new issue
Block a user