mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// permission-manager imports the side-effectful DB barrel and the logger at the
|
|
// top level. Stub both so importing the module does not spin up the real
|
|
// database / encryption stack. We then drive hasPermission via a spied
|
|
// getUserPermissions so we test the wildcard-matching logic in isolation.
|
|
vi.mock("../../database/db/index.js", () => ({ db: {} }));
|
|
vi.mock("../../utils/logger.js", () => ({
|
|
databaseLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const accessState = vi.hoisted(() => ({
|
|
ownerId: "owner" as string,
|
|
grant: null as {
|
|
id: number;
|
|
permissionLevel: string;
|
|
expiresAt: string | null;
|
|
} | null,
|
|
touched: [] as number[],
|
|
adminIds: new Set<string>(),
|
|
rolePermissionCalls: 0,
|
|
rolePermissions: [] as { permissions: string }[],
|
|
ownedHostIds: new Set<number>(),
|
|
visibleGrants: [] as { hostId: number }[],
|
|
ownedQueryCalls: 0,
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentHostResolutionRepository: () => ({
|
|
isHostOwnedByUser: async (_hostId: number, userId: string) =>
|
|
userId === accessState.ownerId,
|
|
findHostOwnerId: async () => accessState.ownerId,
|
|
listOwnedHostIds: async () => {
|
|
accessState.ownedQueryCalls += 1;
|
|
return accessState.ownedHostIds;
|
|
},
|
|
}),
|
|
createCurrentRbacAccessRepository: () => ({
|
|
listVisibleHostAccessEntries: async () => accessState.visibleGrants,
|
|
findActiveHostAccess: async () => accessState.grant,
|
|
touchHostAccess: async (id: number) => {
|
|
accessState.touched.push(id);
|
|
},
|
|
deleteExpiredHostAccess: async () => 0,
|
|
}),
|
|
createCurrentRoleRepository: () => ({
|
|
listUserRoleIds: async () => [],
|
|
listUserRolePermissions: async () => {
|
|
accessState.rolePermissionCalls += 1;
|
|
return accessState.rolePermissions;
|
|
},
|
|
userHasAnyRoleName: async () => false,
|
|
}),
|
|
createCurrentUserRepository: () => ({
|
|
findById: async (userId: string) =>
|
|
accessState.adminIds.has(userId) ? { id: userId, isAdmin: true } : null,
|
|
}),
|
|
}));
|
|
|
|
const { PermissionManager } = await import("../../utils/permission-manager.js");
|
|
|
|
type PermissionManagerInstance = ReturnType<
|
|
typeof PermissionManager.getInstance
|
|
>;
|
|
|
|
describe("PermissionManager.hasPermission wildcard matching", () => {
|
|
let manager: PermissionManagerInstance;
|
|
|
|
function withPermissions(permissions: string[]) {
|
|
vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
manager = PermissionManager.getInstance();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("grants everything for the global wildcard '*'", async () => {
|
|
withPermissions(["*"]);
|
|
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
|
expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true);
|
|
});
|
|
|
|
it("grants an exact permission match", async () => {
|
|
withPermissions(["hosts.read", "hosts.write"]);
|
|
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
|
});
|
|
|
|
it("grants via a prefix wildcard", async () => {
|
|
withPermissions(["hosts.*"]);
|
|
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
|
expect(await manager.hasPermission("u1", "hosts.write")).toBe(true);
|
|
});
|
|
|
|
it("grants via a deep prefix wildcard", async () => {
|
|
withPermissions(["admin.users.*"]);
|
|
expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true);
|
|
});
|
|
|
|
it("denies when no exact or wildcard permission matches", async () => {
|
|
withPermissions(["hosts.read"]);
|
|
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
|
expect(await manager.hasPermission("u1", "credentials.read")).toBe(false);
|
|
});
|
|
|
|
it("denies when the user has no permissions", async () => {
|
|
withPermissions([]);
|
|
expect(await manager.hasPermission("u1", "hosts.read")).toBe(false);
|
|
});
|
|
|
|
it("does not let a narrower wildcard grant a sibling branch", async () => {
|
|
withPermissions(["hosts.read.*"]);
|
|
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
|
});
|
|
|
|
it("resolves wildcards for a runtime-registered permission group", async () => {
|
|
const { registerPluginPermissions, resetPermissionCatalog } =
|
|
await import("../../utils/permission-catalog.js");
|
|
|
|
registerPluginPermissions({
|
|
group: "testplugin",
|
|
pluginId: "testplugin",
|
|
label: "Test Plugin",
|
|
permissions: ["testplugin.view", "testplugin.manage"],
|
|
});
|
|
|
|
try {
|
|
withPermissions(["testplugin.*"]);
|
|
expect(await manager.hasPermission("u1", "testplugin.view")).toBe(true);
|
|
expect(await manager.hasPermission("u1", "testplugin.manage")).toBe(true);
|
|
|
|
withPermissions(["*"]);
|
|
expect(await manager.hasPermission("u1", "testplugin.view")).toBe(true);
|
|
} finally {
|
|
resetPermissionCatalog();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PermissionManager.canAccessHost level hierarchy", () => {
|
|
const manager = PermissionManager.getInstance();
|
|
const actions = ["connect", "view", "edit", "manage"] as const;
|
|
const levels = ["connect", "view", "edit", "manage"] as const;
|
|
const rank = { connect: 1, view: 2, edit: 3, manage: 4 } as const;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
accessState.ownerId = "owner";
|
|
accessState.grant = null;
|
|
accessState.touched = [];
|
|
accessState.adminIds = new Set();
|
|
});
|
|
|
|
it("grants the owner every action including delete", async () => {
|
|
for (const action of [...actions, "delete"] as const) {
|
|
const info = await manager.canAccessHost("owner", 42, action);
|
|
expect(info).toMatchObject({ hasAccess: true, isOwner: true });
|
|
}
|
|
});
|
|
|
|
it("denies everything without a grant", async () => {
|
|
const info = await manager.canAccessHost("stranger", 42, "connect");
|
|
expect(info).toMatchObject({ hasAccess: false, isShared: false });
|
|
});
|
|
|
|
it("enforces the connect < view < edit < manage hierarchy", async () => {
|
|
for (const level of levels) {
|
|
accessState.grant = { id: 5, permissionLevel: level, expiresAt: null };
|
|
for (const action of actions) {
|
|
const info = await manager.canAccessHost("recipient", 42, action);
|
|
expect(info.hasAccess).toBe(rank[level] >= rank[action]);
|
|
expect(info.permissionLevel).toBe(level);
|
|
expect(info.isShared).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never grants delete to a shared recipient", async () => {
|
|
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
|
const info = await manager.canAccessHost("recipient", 42, "delete");
|
|
expect(info.hasAccess).toBe(false);
|
|
});
|
|
|
|
it("normalizes the legacy 'view' string mapping and unknown levels to connect", async () => {
|
|
accessState.grant = { id: 5, permissionLevel: "bogus", expiresAt: null };
|
|
const connect = await manager.canAccessHost("recipient", 42, "connect");
|
|
expect(connect.hasAccess).toBe(true);
|
|
expect(connect.permissionLevel).toBe("connect");
|
|
|
|
const view = await manager.canAccessHost("recipient", 42, "view");
|
|
expect(view.hasAccess).toBe(false);
|
|
});
|
|
|
|
it("only touches the grant timestamp on connect", async () => {
|
|
accessState.grant = { id: 5, permissionLevel: "manage", expiresAt: null };
|
|
await manager.canAccessHost("recipient", 42, "manage");
|
|
expect(accessState.touched).toEqual([]);
|
|
await manager.canAccessHost("recipient", 42, "connect");
|
|
expect(accessState.touched).toEqual([5]);
|
|
});
|
|
|
|
it("grants admins owner-equivalent access to any host via bypass", async () => {
|
|
accessState.adminIds = new Set(["adminUser"]);
|
|
for (const action of actions) {
|
|
const info = await manager.canAccessHost("adminUser", 42, action);
|
|
expect(info).toMatchObject({
|
|
hasAccess: true,
|
|
isOwner: false,
|
|
isAdminBypass: true,
|
|
permissionLevel: "manage",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("upgrades an under-privileged admin's share access via bypass", async () => {
|
|
accessState.adminIds = new Set(["adminUser"]);
|
|
accessState.grant = { id: 7, permissionLevel: "connect", expiresAt: null };
|
|
const info = await manager.canAccessHost("adminUser", 42, "manage");
|
|
expect(info).toMatchObject({ hasAccess: true, isAdminBypass: true });
|
|
});
|
|
|
|
it("does not grant a non-admin stranger admin bypass", async () => {
|
|
const info = await manager.canAccessHost("stranger", 42, "manage");
|
|
expect(info.hasAccess).toBe(false);
|
|
expect(info.isAdminBypass).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("PermissionManager.getUserPermissions caching", () => {
|
|
let manager: PermissionManagerInstance;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
manager = PermissionManager.getInstance();
|
|
accessState.rolePermissionCalls = 0;
|
|
accessState.rolePermissions = [{ permissions: '["hosts.read"]' }];
|
|
manager.invalidateUserPermissionCache("cache-user");
|
|
});
|
|
|
|
it("serves repeat lookups from cache instead of re-querying roles", async () => {
|
|
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
|
"hosts.read",
|
|
]);
|
|
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
|
"hosts.read",
|
|
]);
|
|
|
|
expect(accessState.rolePermissionCalls).toBe(1);
|
|
});
|
|
|
|
it("re-reads roles after an explicit invalidation", async () => {
|
|
await manager.getUserPermissions("cache-user");
|
|
manager.invalidateUserPermissionCache("cache-user");
|
|
accessState.rolePermissions = [{ permissions: '["hosts.write"]' }];
|
|
|
|
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
|
"hosts.write",
|
|
]);
|
|
expect(accessState.rolePermissionCalls).toBe(2);
|
|
});
|
|
|
|
it("expires an entry once its own TTL has passed", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
await manager.getUserPermissions("cache-user");
|
|
// Just past the 5 minute TTL.
|
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
|
await manager.getUserPermissions("cache-user");
|
|
|
|
expect(accessState.rolePermissionCalls).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps a still-fresh entry when the sweep runs", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
await manager.getUserPermissions("cache-user");
|
|
// Fire the periodic sweep without crossing this entry's own TTL. The
|
|
// old implementation cleared the whole map here, expiring every active
|
|
// user at once.
|
|
vi.advanceTimersByTime(5 * 60 * 1000 - 1000);
|
|
await manager.getUserPermissions("cache-user");
|
|
|
|
expect(accessState.rolePermissionCalls).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("returns an empty set rather than throwing when role lookup fails", async () => {
|
|
manager.invalidateUserPermissionCache("boom-user");
|
|
accessState.rolePermissions = [{ permissions: "not-json" }];
|
|
|
|
expect(await manager.getUserPermissions("boom-user")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("PermissionManager.filterAccessibleHostIds", () => {
|
|
let manager: PermissionManagerInstance;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
manager = PermissionManager.getInstance();
|
|
accessState.adminIds = new Set();
|
|
accessState.ownedHostIds = new Set();
|
|
accessState.visibleGrants = [];
|
|
accessState.ownedQueryCalls = 0;
|
|
});
|
|
|
|
it("keeps hosts the user owns", async () => {
|
|
accessState.ownedHostIds = new Set([1, 2]);
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);
|
|
|
|
expect([...allowed].sort()).toEqual([1, 2]);
|
|
});
|
|
|
|
it("keeps hosts shared with the user", async () => {
|
|
accessState.visibleGrants = [{ hostId: 7 }];
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", [7, 8]);
|
|
|
|
expect([...allowed]).toEqual([7]);
|
|
});
|
|
|
|
it("combines owned and shared without duplicating", async () => {
|
|
accessState.ownedHostIds = new Set([1]);
|
|
accessState.visibleGrants = [{ hostId: 1 }, { hostId: 2 }];
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);
|
|
|
|
expect([...allowed].sort()).toEqual([1, 2]);
|
|
});
|
|
|
|
it("excludes another tenant's hosts", async () => {
|
|
accessState.ownedHostIds = new Set([1]);
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", [1, 99, 100]);
|
|
|
|
expect(allowed.has(99)).toBe(false);
|
|
expect(allowed.has(100)).toBe(false);
|
|
});
|
|
|
|
it("gives an admin every host without per-host lookups", async () => {
|
|
accessState.adminIds = new Set(["admin1"]);
|
|
|
|
const allowed = await manager.filterAccessibleHostIds(
|
|
"admin1",
|
|
[1, 2, 3, 4],
|
|
);
|
|
|
|
expect([...allowed].sort()).toEqual([1, 2, 3, 4]);
|
|
});
|
|
|
|
it("resolves the whole fleet with a single owned-hosts query", async () => {
|
|
accessState.ownedHostIds = new Set(
|
|
Array.from({ length: 500 }, (_, i) => i + 1),
|
|
);
|
|
const ids = Array.from({ length: 500 }, (_, i) => i + 1);
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", ids);
|
|
|
|
expect(allowed.size).toBe(500);
|
|
// The point of the batch path: cost does not scale with host count.
|
|
expect(accessState.ownedQueryCalls).toBe(1);
|
|
});
|
|
|
|
it("short-circuits an empty list without querying", async () => {
|
|
const allowed = await manager.filterAccessibleHostIds("u1", []);
|
|
|
|
expect(allowed.size).toBe(0);
|
|
expect(accessState.ownedQueryCalls).toBe(0);
|
|
});
|
|
|
|
it("fails closed when the lookup throws", async () => {
|
|
accessState.ownedHostIds = null as unknown as Set<number>;
|
|
|
|
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2]);
|
|
|
|
expect(allowed.size).toBe(0);
|
|
});
|
|
});
|