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:
+156
-21
@@ -66,7 +66,14 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
|
||||
}
|
||||
|
||||
// The workspaces a user owns or that are shared with them (2 params: uid, uid).
|
||||
const MY_LANES = "SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?";
|
||||
const MY_LANES =
|
||||
"SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?";
|
||||
// The sessions shared with a user one by one (1 param: uid), and the
|
||||
// workspaces holding them (sharing phase 3: seen, but only that session).
|
||||
const MY_SESSIONS = "SELECT session_id FROM session_shares WHERE user_id = ?";
|
||||
const SESSION_LANES = `SELECT lane_id FROM sessions WHERE lane_id IS NOT NULL AND id IN (${MY_SESSIONS})`;
|
||||
// Every workspace a user may open (3 params: uid ×3).
|
||||
const SEEN_LANES = `${MY_LANES} UNION ${SESSION_LANES}`;
|
||||
|
||||
function ownedSessionWhere(uid, col, laneCol, shared = false) {
|
||||
if (uid == null) return { where: "1", params: [] };
|
||||
@@ -77,8 +84,13 @@ function ownedSessionWhere(uid, col, laneCol, shared = false) {
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r
|
||||
WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [...lp, ...lp, uid, uid],
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${
|
||||
shared
|
||||
? `
|
||||
OR ${col} IN (${MY_SESSIONS})`
|
||||
: ""
|
||||
})`,
|
||||
params: [...lp, ...lp, uid, uid, ...(shared ? [uid] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +106,36 @@ function bySessionWhere(req, col = "session_id") {
|
||||
function laneWhere(req, col = "id") {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return { where: "1", params: [] };
|
||||
return { where: `${col} IN (${MY_LANES})`, params: [uid, uid] };
|
||||
return { where: `${col} IN (${SEEN_LANES})`, params: [uid, uid, uid] };
|
||||
}
|
||||
|
||||
/** Whether session `sessionId` is shared with `userId` on its own (phase 3). */
|
||||
function sessionSharedWith(sessionId, userId) {
|
||||
if (!sessionId || userId == null) return false;
|
||||
return Boolean(
|
||||
sql("SELECT 1 FROM session_shares WHERE session_id = ? AND user_id = ?").get(
|
||||
String(sessionId),
|
||||
userId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of workspace `laneId` this request may use: `all` (local mode, an
|
||||
* admin or analyst read, its owner, a user it is shared with), `session`
|
||||
* (only sessions shared with them one by one), or null.
|
||||
*/
|
||||
function laneAccess(req, laneId) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return "all";
|
||||
const v = typeof laneId === "number" ? laneId : String(laneId);
|
||||
const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v);
|
||||
if (!row) return null;
|
||||
if (row.owner_id === uid || sharedWith(row.id, uid)) return "all";
|
||||
const one = sql(
|
||||
`SELECT 1 FROM sessions WHERE lane_id = ? AND id IN (${MY_SESSIONS}) LIMIT 1`
|
||||
).get(row.id, uid);
|
||||
return one ? "session" : null;
|
||||
}
|
||||
|
||||
/** Whether `userId` is one of the users workspace `laneId` is shared with. */
|
||||
@@ -123,9 +164,7 @@ function canSeeLane(req, laneId) {
|
||||
// Bound as given, like getLane binds a route param: SQLite's integer
|
||||
// affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked
|
||||
// here is the row the route would load.
|
||||
const v = typeof laneId === "number" ? laneId : String(laneId);
|
||||
const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v);
|
||||
return Boolean(row) && (row.owner_id === uid || sharedWith(row.id, uid));
|
||||
return laneAccess(req, laneId) != null;
|
||||
}
|
||||
|
||||
function canSeeSession(req, sessionId) {
|
||||
@@ -146,7 +185,13 @@ function canSeeAgent(req, agentId) {
|
||||
function canSeeRun(req, runId) {
|
||||
if (memberId(req) == null) return true;
|
||||
const m = /^agenthub-lane-(\d+)(?:-\d+)?(?:@.*)?$/.exec(String(runId || ""));
|
||||
if (m) return canSeeLane(req, m[1]);
|
||||
if (m) {
|
||||
const access = laneAccess(req, m[1]);
|
||||
if (access !== "session") return access != null;
|
||||
// A session shared on its own: only the pane holding that session.
|
||||
const row = sql("SELECT session_id FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||||
return Boolean(row?.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||||
}
|
||||
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||||
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
|
||||
}
|
||||
@@ -155,9 +200,9 @@ function canSeeRun(req, runId) {
|
||||
function visibleLaneIds(req) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return null;
|
||||
return sql(MY_LANES)
|
||||
.all(uid, uid)
|
||||
.map((r) => r.id);
|
||||
return sql(SEEN_LANES)
|
||||
.all(uid, uid, uid)
|
||||
.map((r) => r.lane_id ?? r.id);
|
||||
}
|
||||
|
||||
/** A row of `table` (`id` = `rowId`) whose `session_id` this request may see. */
|
||||
@@ -170,8 +215,11 @@ function canSeeRowSession(req, table, rowId, idCol = "id") {
|
||||
/** A `dashboard_runs` row (`/api/run/history/:id`) of one of this request's workspaces. */
|
||||
function canSeeRunRow(req, id) {
|
||||
if (memberId(req) == null) return true;
|
||||
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(id));
|
||||
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
|
||||
const row = sql("SELECT lane_id, session_id FROM dashboard_runs WHERE id = ?").get(String(id));
|
||||
if (!row || row.lane_id == null) return false;
|
||||
const access = laneAccess(req, row.lane_id);
|
||||
if (access !== "session") return access != null;
|
||||
return Boolean(row.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,6 +320,28 @@ function scopeGuard(req, res, next) {
|
||||
if (bad) return notFound();
|
||||
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
|
||||
if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
|
||||
// A workspace seen only through a session shared on its own (phase 3):
|
||||
// that session, its pane and the workspace's Changes — nothing else of it.
|
||||
const write = req.method !== "GET" && req.method !== "HEAD";
|
||||
const pm = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(p);
|
||||
if (
|
||||
pm &&
|
||||
laneAccess(req, Number(pm[1])) === "session" &&
|
||||
!sessionShareAllows(req, pm[2] || "", Number(pm[1]))
|
||||
) {
|
||||
return sessionOnly(res);
|
||||
}
|
||||
const viaSession = lanes.some((id) => laneAccess(req, Number(id)) === "session");
|
||||
// Workspace-wide reads by `?laneId=`: files and skills of every session, stage history.
|
||||
if (
|
||||
viaSession &&
|
||||
(write || /^\/(artifacts|lanes\/pipeline-stats|workspaces\/pipeline-stats)(\/|$)/i.test(p))
|
||||
) {
|
||||
return sessionOnly(res);
|
||||
}
|
||||
// The shared session itself: open it, never rename or archive it.
|
||||
const sm = /^\/sessions\/([^/]+)/i.exec(p);
|
||||
if (write && sm && sessionOnlyVia(req, sm[1])) return sessionOnly(res);
|
||||
// S3: a WRITE to a workspace re-checks that its owner may still own that
|
||||
// folder, by the LIVE role — an admin demoted since keeps owning their
|
||||
// host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a
|
||||
@@ -292,6 +362,54 @@ function scopeGuard(req, res, next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
/** Whether this request sees session `sid` only because it is shared with them on its own. */
|
||||
function sessionOnlyVia(req, sid) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null || !sessionSharedWith(sid, uid)) return false;
|
||||
const row = sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sid));
|
||||
return !row?.lane_id || laneAccess(req, row.lane_id) === "session";
|
||||
}
|
||||
|
||||
const sessionOnly = (res) =>
|
||||
res.status(403).json({
|
||||
error: {
|
||||
code: "ESESSIONSHARE",
|
||||
message: "only a session of this workspace is shared with you: open that session",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* What a user with only a session of workspace (rest = the path after
|
||||
* `/lanes/<id>`) may call there: reads of the workspace, its git, files and
|
||||
* share list (not its instructions or context); git writes; and a `start` that
|
||||
* resumes a session shared with them — no new session, fork or terminal.
|
||||
*/
|
||||
function sessionShareAllows(req, rest, laneId) {
|
||||
const r = rest.toLowerCase();
|
||||
// A workspace's extra folders are its own, not the session's.
|
||||
if (req.query?.source !== undefined) return false;
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
return r === "" || r === "/" || /^\/(git|fs|shares)(\/|$)/.test(r);
|
||||
}
|
||||
if (/^\/git\//.test(r)) return true;
|
||||
if (r === "/start") {
|
||||
const b = req.body && typeof req.body === "object" ? req.body : {};
|
||||
return (
|
||||
typeof b.resumeSessionId === "string" &&
|
||||
b.forkSession !== true &&
|
||||
b.shell !== true &&
|
||||
b.resume !== true &&
|
||||
// A free slot the server picks: pane 0 is the workspace's own run.
|
||||
(b.slot === undefined || b.slot === "auto") &&
|
||||
sessionSharedWith(b.resumeSessionId, memberId(req)) &&
|
||||
Boolean(
|
||||
sql("SELECT 1 FROM sessions WHERE id = ? AND lane_id = ?").get(b.resumeSessionId, laneId)
|
||||
)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a workspace's owner may still own its folder (S3, live role). */
|
||||
function ownerStillFits(laneId) {
|
||||
const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
@@ -329,12 +447,23 @@ function laneOwners(laneId) {
|
||||
return new Set(row && row.owner_id != null ? [row.owner_id] : []);
|
||||
}
|
||||
|
||||
/** The users a workspace is shared with — they get its news, but it is not "theirs". */
|
||||
function laneViewers(laneId) {
|
||||
/**
|
||||
* The users a workspace is shared with — they get its news, but it is not
|
||||
* "theirs". `sessions`: also those with only a session of it (its row
|
||||
* changes, never its stack's output).
|
||||
*/
|
||||
function laneViewers(laneId, { sessions = false } = {}) {
|
||||
if (laneId == null) return new Set();
|
||||
return new Set(
|
||||
sql("SELECT user_id FROM lane_shares WHERE lane_id = ?")
|
||||
.all(Number(laneId))
|
||||
sql(
|
||||
`SELECT user_id FROM lane_shares WHERE lane_id = ?${
|
||||
sessions
|
||||
? ` UNION SELECT user_id FROM session_shares
|
||||
WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)`
|
||||
: ""
|
||||
}`
|
||||
)
|
||||
.all(...(sessions ? [Number(laneId), Number(laneId)] : [Number(laneId)]))
|
||||
.map((r) => r.user_id)
|
||||
);
|
||||
}
|
||||
@@ -346,9 +475,10 @@ function sessionViewers(sessionId) {
|
||||
sql(
|
||||
`SELECT user_id FROM lane_shares WHERE lane_id IN (
|
||||
SELECT lane_id FROM sessions WHERE id = ? AND lane_id IS NOT NULL
|
||||
UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)`
|
||||
UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)
|
||||
UNION SELECT user_id FROM session_shares WHERE session_id = ?`
|
||||
)
|
||||
.all(String(sessionId), String(sessionId))
|
||||
.all(String(sessionId), String(sessionId), String(sessionId))
|
||||
.map((r) => r.user_id)
|
||||
);
|
||||
}
|
||||
@@ -380,7 +510,10 @@ function broadcastAudience(type, data) {
|
||||
case "lane_update":
|
||||
// A removal names an id that no longer resolves; it reveals nothing.
|
||||
if (d.removed != null && !d.lane) return null;
|
||||
return { owners: laneOwners(d.lane?.id), viewers: laneViewers(d.lane?.id) };
|
||||
return {
|
||||
owners: laneOwners(d.lane?.id),
|
||||
viewers: laneViewers(d.lane?.id, { sessions: true }),
|
||||
};
|
||||
case "lane_hook_output":
|
||||
case "lane_hook_result":
|
||||
case "lane_runtime":
|
||||
@@ -433,7 +566,7 @@ function audienceIncludes(audience, user) {
|
||||
if (audience === null || audience.strip) return true;
|
||||
return Boolean(
|
||||
(audience.owners && audience.owners.has(user.id)) ||
|
||||
(audience.viewers && audience.viewers.has(user.id))
|
||||
(audience.viewers && audience.viewers.has(user.id))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -556,6 +689,8 @@ module.exports = {
|
||||
PATH_RULES,
|
||||
canSeeRowSession,
|
||||
canSeeRunRow,
|
||||
laneAccess,
|
||||
sessionSharedWith,
|
||||
folderAllowed,
|
||||
ownFolder,
|
||||
memberId,
|
||||
|
||||
Reference in New Issue
Block a user