feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3)
A workspace's owner (or an admin) can share ONE session from its right-click menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares, audited). The user sees the workspace (access: "session") with only that session, joins the pane holding it, resumes it on their own account in a free slot, and uses the workspace's git and Files reads and git writes; everything else of the workspace answers 403 ESESSIONSHARE (scope.js sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full collaborators. A collaborator's pane token (readable by the owner's Linux account) now acts as them inside that workspace only, and never starts or bills a session (auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an admin opens acts as the owner again. Docs say plainly that under S3 a session share hands over the owner's account inside the terminal.
This commit is contained in:
+62
-6
@@ -258,9 +258,36 @@ function validateProfile(body) {
|
||||
|
||||
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
|
||||
const CONFUSABLE = {
|
||||
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
|
||||
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
|
||||
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
|
||||
а: "a",
|
||||
в: "b",
|
||||
е: "e",
|
||||
к: "k",
|
||||
м: "m",
|
||||
н: "h",
|
||||
о: "o",
|
||||
р: "p",
|
||||
с: "c",
|
||||
т: "t",
|
||||
у: "y",
|
||||
х: "x",
|
||||
і: "i",
|
||||
ј: "j",
|
||||
ԁ: "d",
|
||||
ѕ: "s",
|
||||
ӏ: "l",
|
||||
α: "a",
|
||||
β: "b",
|
||||
ε: "e",
|
||||
η: "n",
|
||||
ι: "i",
|
||||
κ: "k",
|
||||
ν: "v",
|
||||
ο: "o",
|
||||
ρ: "p",
|
||||
τ: "t",
|
||||
υ: "u",
|
||||
χ: "x",
|
||||
ı: "i",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -519,6 +546,7 @@ function deleteUser(username) {
|
||||
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||||
// Workspaces other users shared with them (lane_shares has no foreign key).
|
||||
sql("DELETE FROM lane_shares WHERE user_id = ?").run(u.id);
|
||||
sql("DELETE FROM session_shares WHERE user_id = ?").run(u.id);
|
||||
require("./avatars").removeAvatar(u.id);
|
||||
try {
|
||||
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||||
@@ -549,6 +577,9 @@ function assignWorkspace(laneId, username) {
|
||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||
// Its new owner no longer needs it shared with them.
|
||||
sql("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(Number(laneId), u.id);
|
||||
sql(
|
||||
"DELETE FROM session_shares WHERE user_id = ? AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?)"
|
||||
).run(u.id, Number(laneId));
|
||||
}
|
||||
|
||||
/** Take a workspace back from the user who owns it: it is left with no owner
|
||||
@@ -741,7 +772,7 @@ function userFromPaneToken(req) {
|
||||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||||
if (!isLocalRequest(req)) return null;
|
||||
const row = sql(
|
||||
`SELECT r.id, r.lane_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
`SELECT r.id, r.lane_id, r.session_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||||
).get(sha256(t));
|
||||
if (!row || row.owner_id == null) return null;
|
||||
@@ -754,11 +785,33 @@ function userFromPaneToken(req) {
|
||||
if (
|
||||
uid !== row.owner_id &&
|
||||
user.role !== "admin" &&
|
||||
!require("./scope").sharedWith(row.lane_id, uid)
|
||||
!require("./scope").sharedWith(row.lane_id, uid) &&
|
||||
!require("./scope").sessionSharedWith(row.session_id, uid)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return publicUser(user) || null;
|
||||
const pub = publicUser(user);
|
||||
if (!pub) return null;
|
||||
// A collaborator's pane runs as the OWNER's Linux account, which can read
|
||||
// its token: it acts as the collaborator inside this workspace only
|
||||
// (`paneLane`, authGuard), never across their account.
|
||||
return uid !== row.owner_id ? { ...pub, paneLane: row.lane_id } : pub;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a collaborator's pane token may call — the CLI's workspace commands,
|
||||
* nothing that starts or bills a session (the owner's account can read the
|
||||
* token): reads of its own workspace (and the workspace list, which answers
|
||||
* that one only), health, and its stage / stack / hook / feature writes.
|
||||
*/
|
||||
function paneLaneAllows(method, lp, laneId) {
|
||||
const read = method === "GET" || method === "HEAD";
|
||||
if (lp === "/health") return true;
|
||||
if (read && /^\/(?:lanes|workspaces)\/?$/i.test(lp)) return true;
|
||||
const m = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(lp);
|
||||
if (!m || Number(m[1]) !== laneId) return false;
|
||||
if (read) return true;
|
||||
return /^\/(stage|up|down|hook\/[^/]+|features\/activate|mcp\/sync)$/i.test(m[2] || "");
|
||||
}
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────────────
|
||||
@@ -814,6 +867,9 @@ function authGuard(req, res, next) {
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
if (!cookieUser && user.paneLane != null && !paneLaneAllows(req.method, lp, user.paneLane)) {
|
||||
return deny(res, 403, "EPANESCOPE", "this pane's token acts only inside its own workspace");
|
||||
}
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
|
||||
Reference in New Issue
Block a user