feat: connect policy hook so plugins can require approved access

This commit is contained in:
LukeGus committed 2026-11-05 22:28:38 -06:00
1 parent 1f5a19f54d
commit 67b70f8a97
20 files changed
+549 -7

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
- A click on a host can open its host page, when a plugin offers one
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
### Changed
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 1.2.0
Plugin API 1.16.0.
### Added
- `ctx.hosts.connectPolicy.provide` and the `hosts:connect-policy` capability, so a plugin can allow, deny or ask for an access request before an SSH or remote desktop session opens
- SSH prepare outcomes can carry the error codes `access-denied` and `access-required` with a `requestTab`
## 1.1.0
Plugin API 1.15.0.
+9 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.1.0 | Plugin API 1.15.0
SDK 1.2.0 | Plugin API 1.16.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -354,6 +354,13 @@ router.post("/notes", async (req, res) => {
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
`ctx.credentials.list()` finds the acting user's credentials by name.
- Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked
before every SSH config is built and every remote desktop login is handed out (`input` has `userId`,
`hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`,
`{ decision: "deny", reason }` or `{ decision: "request", reason, requestTab }`. The strictest answer of
all providers wins and a provider that throws denies. SSH prepare then gives an error outcome with code
`access-denied` or `access-required`, and `ctx.credentials.resolveHostProtocol` throws an error with that
`code` and a `toJSON()` body for a 403. Keep `check` fast, it also runs for background polling.
### Errors
@@ -1085,6 +1092,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `hosts:connect-policy` | high | Allow, deny or ask for approval before sessions open |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.1.0",
"version": "1.2.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -171,6 +171,7 @@
"hosts:status-provide",
"hosts:activity-provide",
"hosts:lock",
"hosts:connect-policy",
"desktop:window",
"desktop:local",
"users:impersonate",
+58 -2
View File
@@ -1295,6 +1295,54 @@ export interface PluginHostLocksApi {
provide: (provider: PluginHostLockProvider) => () => void;
}
/** What core asks a connect policy before a session to a host opens. */
export interface PluginConnectPolicyInput {
/** The person connecting. */
userId: string;
hostId: number;
/** "ssh", or a remote desktop protocol id such as "rdp", "vnc" or "telnet". */
protocol: string;
/** Free form purpose the caller gave, such as "terminal" or "sftp". */
purpose: string;
/**
* SSH profile: terminal, session, stream, forward, background or jump.
* "desktop" for remote desktop logins.
*/
profile: string;
/** True when a person waits on this connect (a prompt channel is open). */
interactive: boolean;
/** Plugin id that asked for the connection, or "core". */
origin: string;
}
export type PluginConnectPolicyDecision =
| { decision: "allow" }
| { decision: "deny"; reason?: string }
| {
decision: "request";
reason?: string;
/** A tab id of the providing plugin where the user can ask for access. */
requestTab?: string;
};
/**
* Decides whether a user may open a session to a host. Core asks every
* provider before it builds an SSH config (terminal, file manager, tunnels,
* background exec) and before it hands out remote desktop logins. The
* strictest answer wins and a provider that throws denies. Keep check fast:
* it runs on every connect, including background polling.
*/
export interface PluginConnectPolicyProvider {
check: (
input: PluginConnectPolicyInput,
) => PluginConnectPolicyDecision | Promise<PluginConnectPolicyDecision>;
}
export interface PluginConnectPolicyApi {
/** Registers this plugin's policy. Needs hosts:connect-policy. Removed on deactivate. */
provide: (provider: PluginConnectPolicyProvider) => () => void;
}
/**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own.
@@ -1441,6 +1489,8 @@ export interface PluginHosts {
activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */
locks: PluginHostLocksApi;
/** Who may open sessions to which hosts. */
connectPolicy: PluginConnectPolicyApi;
/** Folder rows for the acting user. Needs hosts:write. */
folders: PluginHostFoldersApi;
}
@@ -1668,8 +1718,12 @@ export type PluginSshAuthOutcome =
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
| "failed"
| "access-denied"
| "access-required";
message: string;
/** For access-required: a tab of the policy plugin where access is asked for. */
requestTab?: string;
}
| {
/** Try once more with these config changes. */
@@ -2695,7 +2749,9 @@ export interface PluginCredentials {
/**
* The host's login for one of this plugin's own contributes.protocols.
* Null when the host does not exist or the acting user cannot connect to
* it. Needs credentials:read. Audited.
* it. Throws an error with `code` "access-denied" or "access-required"
* (and `requestTab`, `toJSON()` for a 403 body) when a connect policy
* refuses the session. Needs credentials:read. Audited.
*/
resolveHostProtocol: (
hostId: number,
+1
View File
@@ -67,6 +67,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:status-provide", "medium"),
entry("hosts:activity-provide", "medium"),
entry("hosts:lock", "medium"),
entry("hosts:connect-policy", "high"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
+1 -1
View File
@@ -42,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.15.0";
export const PLUGIN_API_VERSION = "1.16.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+20
View File
@@ -81,6 +81,7 @@ import type {
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginConnectPolicyProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
@@ -303,6 +304,8 @@ export interface FakePluginContext {
activityProviders: PluginActivityProvider[];
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
hostLockProviders: PluginHostLockProvider[];
/** Every provider passed to ctx.hosts.connectPolicy.provide that is still registered. */
connectPolicyProviders: PluginConnectPolicyProvider[];
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
@@ -549,6 +552,7 @@ export function createFakeContext(
const hostStatusProviders: PluginHostStatusProvider[] = [];
const activityProviders: PluginActivityProvider[] = [];
const hostLockProviders: PluginHostLockProvider[] = [];
const connectPolicyProviders: PluginConnectPolicyProvider[] = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
@@ -1093,6 +1097,15 @@ export function createFakeContext(
};
},
},
connectPolicy: {
provide: (provider) => {
connectPolicyProviders.push(provider);
return () => {
const at = connectPolicyProviders.indexOf(provider);
if (at >= 0) connectPolicyProviders.splice(at, 1);
};
},
},
status: {
provide: (provider) => {
hostStatusProviders.push(provider);
@@ -1586,6 +1599,7 @@ export function createFakeContext(
hostStatusProviders,
activityProviders,
hostLockProviders,
connectPolicyProviders,
emitted,
kv,
tables,
@@ -2080,6 +2094,12 @@ export function createMockCtx(
return ctx.hosts.locks.provide(provider);
},
},
connectPolicy: {
provide: (provider) => {
require("hosts:connect-policy");
return ctx.hosts.connectPolicy.provide(provider);
},
},
status: {
provide: (provider) => {
require("hosts:read");
+165
View File
@@ -0,0 +1,165 @@
/**
* Connect policy from plugins through ctx.hosts.connectPolicy.provide. Core
* asks every provider before it builds an SSH config or hands out a remote
* desktop login, and the strictest answer wins: deny, then request, then
* allow. A provider that throws denies, since letting a session through when
* the access rule is broken is worse than a failed connect. No provider means
* allow.
*/
import type {
PluginConnectPolicyDecision,
PluginConnectPolicyInput,
PluginConnectPolicyProvider,
} from "@termix-ssh/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
interface Entry {
pluginId: string;
provider: PluginConnectPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export type ConnectPolicyResult =
| { decision: "allow" }
| {
decision: "deny" | "request";
reason: string;
pluginId: string;
requestTab: string | null;
};
export class ConnectPolicyError extends Error {
readonly status = 403;
readonly code: "access-denied" | "access-required";
constructor(
readonly result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
) {
super(result.reason);
this.name = "ConnectPolicyError";
this.code =
result.decision === "request" ? "access-required" : "access-denied";
}
/** The body routes send back, so a client can offer the request tab. */
toJSON(): {
error: string;
code: string;
requestTab: string | null;
pluginId: string;
} {
return {
error: this.message,
code: this.code,
requestTab: this.result.requestTab,
pluginId: this.result.pluginId,
};
}
}
export function provideConnectPolicy(
pluginId: string,
provider: PluginConnectPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearConnectPolicies(): void {
providers.clear();
}
export function hasConnectPolicies(): boolean {
return providers.size > 0;
}
const RANK = { allow: 0, request: 1, deny: 2 } as const;
function plainTab(value: unknown): string | null {
return typeof value === "string" &&
/^[a-z0-9][a-z0-9:_.-]{0,80}$/i.test(value)
? value
: null;
}
export async function evaluateConnectPolicy(
input: PluginConnectPolicyInput,
): Promise<ConnectPolicyResult> {
let result: ConnectPolicyResult = { decision: "allow" };
for (const entry of providers.values()) {
let decision: PluginConnectPolicyDecision;
try {
decision = (await entry.provider.check(input)) ?? { decision: "allow" };
} catch (error) {
pluginLogger.warn("Connect policy failed, refusing the connection", {
operation: "connect_policy",
pluginId: entry.pluginId,
hostId: input.hostId,
error: error instanceof Error ? error.message : String(error),
});
decision = {
decision: "deny",
reason: "Access to this host can't be checked right now",
};
}
const kind =
decision.decision === "deny" || decision.decision === "request"
? decision.decision
: "allow";
if (RANK[kind] <= RANK[result.decision]) continue;
if (kind === "allow") continue;
const reason =
"reason" in decision && typeof decision.reason === "string"
? decision.reason.slice(0, 300)
: "";
result = {
decision: kind,
reason:
reason ||
(kind === "request"
? "This host needs approved access"
: "Access to this host is not allowed"),
pluginId: entry.pluginId,
requestTab:
kind === "request" && "requestTab" in decision
? plainTab(decision.requestTab)
: null,
};
}
return result;
}
async function auditRefusal(
input: PluginConnectPolicyInput,
result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
): Promise<void> {
const audit = await import("../utils/audit-logger.js");
const username = await audit
.getAuditUsername(input.userId)
.catch(() => input.userId);
void audit.logAudit({
userId: input.userId,
username,
action: "connect_policy_denied",
resourceType: "host",
resourceId: String(input.hostId),
details: `${input.protocol} ${result.decision} by ${result.pluginId}: ${result.reason}`,
success: false,
});
}
/** Throws ConnectPolicyError when a provider denies or wants a request. */
export async function assertConnectAllowed(
input: PluginConnectPolicyInput,
): Promise<void> {
if (providers.size === 0) return;
const result = await evaluateConnectPolicy(input);
if (result.decision === "allow") return;
await auditRefusal(input, result).catch(() => {});
throw new ConnectPolicyError(result);
}
@@ -8,6 +8,7 @@ import {
requireSshAuthProvider,
} from "./auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import { ConnectPolicyError, assertConnectAllowed } from "../connect-policy.js";
import type {
MutableConnectConfig,
SshAuthEnv,
@@ -113,6 +114,8 @@ export interface BuildConnectConfigOptions {
/** Terminal socket for the changed-host-key prompt. */
hostKeySocket?: WebSocket | null;
interactive?: boolean;
/** Plugin id that asked for the connection, or "core". For connect policies. */
origin?: string;
log?: SshAuthLog;
/** Applied after the purpose defaults and before auth is prepared. */
overrides?: Partial<MutableConnectConfig>;
@@ -197,6 +200,35 @@ export async function buildConnectConfig(
log: options.log ?? noopLog,
};
try {
await assertConnectAllowed({
userId: options.userId,
hostId: serverHostId,
protocol: "ssh",
purpose: String(options.purpose),
profile,
interactive: options.interactive ?? false,
origin: options.origin ?? "core",
});
} catch (error) {
if (error instanceof ConnectPolicyError) {
return {
config,
provider: null,
env,
outcome: {
status: "error",
code: error.code,
message: error.message,
...(error.result.requestTab
? { requestTab: error.result.requestTab }
: {}),
},
};
}
throw error;
}
let provider: SshAuthProvider;
try {
provider = requireSshAuthProvider(authType);
@@ -72,6 +72,8 @@ export interface ConnectHostOptions {
*/
sock?: MutableConnectConfig["sock"];
log?: SshAuthLog;
/** Plugin id that asked for the connection. For connect policies. */
origin?: string;
}
export interface SshConnection {
@@ -107,6 +109,7 @@ export async function connectHost(
client,
hostKeySocket: options.prompt?.hostKeySocket ?? null,
interactive: !!options.prompt,
origin: options.origin,
log: options.log,
overrides: options.overrides,
});
+5 -1
View File
@@ -98,8 +98,12 @@ export type SshAuthOutcome =
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
| "failed"
| "access-denied"
| "access-required";
message: string;
/** For access-required: a tab of the policy plugin where access is asked for. */
requestTab?: string;
}
| {
/** Try once more with these config changes. Used by Tailscale. */
+21
View File
@@ -354,6 +354,27 @@ export function createPluginCredentials({
);
}
// Same connect policy as SSH: a remote desktop login is a session.
const { assertConnectAllowed } =
await import("../hosts/connect-policy.js");
try {
await assertConnectAllowed({
userId,
hostId,
protocol: String(protocol),
purpose: "desktop-login",
profile: "desktop",
interactive: true,
origin: pluginId,
});
} catch (error) {
await audit("credentials_read", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentHostResolutionRepository();
+20
View File
@@ -41,6 +41,7 @@ import {
assertManualCreateAllowed,
provideHostLocks,
} from "../hosts/locks.js";
import { provideConnectPolicy } from "../hosts/connect-policy.js";
type AuditFn = (
action: string,
@@ -1041,6 +1042,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
},
},
connectPolicy: {
provide: (provider) => {
if (!declared.includes("hosts:connect-policy")) {
throw capabilityRefused(pluginId, "hosts:connect-policy");
}
const remove = provideConnectPolicy(pluginId, provider);
void audit(
"host_connect_policy_provide",
`${pluginId} decides who can connect`,
{ success: true },
);
const drop = bag?.add(remove, "connect policy provider");
return () => {
drop?.();
remove();
};
},
},
folders: {
ensure: async (name, style) => {
await requireWrite();
+2
View File
@@ -263,6 +263,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
origin: pluginId,
timeoutMs: options?.timeoutMs,
prompt: options?.prompt,
overrides: options?.overrides as Partial<MutableConnectConfig>,
@@ -478,6 +479,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
serverHostId: options.serverHostId,
log: options.log,
interactive: options.interactive,
origin: pluginId,
hostKeySocket: (options.hostKeySocket ?? null) as never,
});
await audit("ssh_prepare", describeHost(host), {
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const logAudit = vi.fn();
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: (params: unknown) => logAudit(params),
getAuditUsername: async (userId: string) => `name-${userId}`,
}));
const {
assertConnectAllowed,
clearConnectPolicies,
ConnectPolicyError,
evaluateConnectPolicy,
hasConnectPolicies,
provideConnectPolicy,
} = await import("../../hosts/connect-policy.js");
const input = {
userId: "u1",
hostId: 4,
protocol: "ssh",
purpose: "terminal",
profile: "terminal",
interactive: true,
origin: "ssh-terminal",
};
afterEach(() => {
clearConnectPolicies();
logAudit.mockReset();
});
describe("connect policy", () => {
it("allows with no provider", async () => {
expect(hasConnectPolicies()).toBe(false);
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
expect(await evaluateConnectPolicy(input)).toEqual({ decision: "allow" });
});
it("passes the input to the provider", async () => {
const check = vi.fn(() => ({ decision: "allow" as const }));
provideConnectPolicy("p", { check });
await assertConnectAllowed(input);
expect(check).toHaveBeenCalledWith(input);
});
it("picks the strictest answer", async () => {
provideConnectPolicy("a", {
check: () => ({ decision: "request", reason: "ask", requestTab: "pa" }),
});
provideConnectPolicy("b", { check: () => ({ decision: "allow" }) });
expect(await evaluateConnectPolicy(input)).toEqual({
decision: "request",
reason: "ask",
pluginId: "a",
requestTab: "pa",
});
provideConnectPolicy("c", {
check: async () => ({ decision: "deny", reason: "no" }),
});
const result = await evaluateConnectPolicy(input);
expect(result).toMatchObject({ decision: "deny", pluginId: "c" });
});
it("denies when a provider throws", async () => {
provideConnectPolicy("broken", {
check: () => {
throw new Error("boom");
},
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
decision: "deny",
pluginId: "broken",
});
});
it("throws a typed error and audits the refusal", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "access" }),
});
const error = await assertConnectAllowed(input).catch((e) => e);
expect(error).toBeInstanceOf(ConnectPolicyError);
expect(error.code).toBe("access-required");
expect(error.status).toBe(403);
expect(error.toJSON()).toEqual({
error: "This host needs approved access",
code: "access-required",
requestTab: "access",
pluginId: "pa",
});
expect(logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "connect_policy_denied",
userId: "u1",
username: "name-u1",
resourceId: "4",
success: false,
}),
);
});
it("drops a request tab that is not a plain id", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "../x y" }),
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
requestTab: null,
});
});
it("stops asking once removed", async () => {
const remove = provideConnectPolicy("pa", {
check: () => ({ decision: "deny" }),
});
remove();
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
import { FakeSshClient } from "./pipeline-test-helpers.js";
@@ -83,6 +83,10 @@ import {
} from "../../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
import type { SshConnectHost } from "../../../hosts/connect/types.js";
import {
clearConnectPolicies,
provideConnectPolicy,
} from "../../../hosts/connect-policy.js";
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
@@ -405,6 +409,46 @@ describe("profile defaults", () => {
});
});
describe("connect policy", () => {
afterEach(() => clearConnectPolicies());
it("refuses before auth is prepared", async () => {
const check = vi.fn(() => ({
decision: "request" as const,
reason: "Ask first",
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
const built = await build(host());
expect(built.provider).toBeNull();
expect(built.outcome).toEqual({
status: "error",
code: "access-required",
message: "Ask first",
requestTab: "access",
});
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
hostId: 7,
protocol: "ssh",
profile: "terminal",
origin: "core",
}),
);
});
it("connectHost throws with the policy code", async () => {
provideConnectPolicy("pa", { check: () => ({ decision: "deny" }) });
const error = await connectHost(host(), {
userId: "user-1",
purpose: "test",
}).catch((e) => e);
expect(error).toBeInstanceOf(SshConnectError);
expect(error.code).toBe("access-denied");
});
});
describe("provider registry", () => {
it("registers and removes a plugin provider", async () => {
ensureCoreSshAuthProviders();
@@ -232,6 +232,36 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(state.ownerDecrypts).toBe(0);
});
it("asks the connect policy and refuses before decrypting", async () => {
grants.set("demo", ["credentials:read"]);
const { provideConnectPolicy, clearConnectPolicies } =
await import("../../hosts/connect-policy.js");
const check = vi.fn(() => ({
decision: "request" as const,
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
try {
const ctx = spiceContext(["credentials:read"]);
const error = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
).catch((e) => e);
expect(error.code).toBe("access-required");
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "owner",
hostId: 7,
protocol: "spice",
profile: "desktop",
origin: "demo",
}),
);
expect(state.ownerDecrypts).toBe(0);
} finally {
clearConnectPolicies();
}
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
+4
View File
@@ -1251,6 +1251,10 @@
"title": "Manage hosts as read only",
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
},
"hosts:connect-policy": {
"title": "Decide who can connect",
"consequence": "Can block or allow every SSH and remote desktop connection to any host."
},
"desktop:window": {
"title": "Open desktop windows",
"consequence": "Only in the Termix desktop app."