@volter/teams 0.2.36 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admission.mjs +22 -1
- package/package.json +1 -1
- package/server/http.mjs +13 -2
- package/server/machine-link.mjs +72 -22
- package/server/permissions.mjs +15 -1
package/admission.mjs
CHANGED
|
@@ -9,6 +9,27 @@
|
|
|
9
9
|
// `<kind>:*` for every target of one kind, or `*` (operate and view only).
|
|
10
10
|
// `root:<path>` covers the path and everything under it.
|
|
11
11
|
|
|
12
|
+
const MACHINE_CAPS = new Set(['operate', 'view', 'observe', 'interact', 'control', 'files', 'ports', 'mail']);
|
|
13
|
+
const MACHINE_TARGET = /^(\*|root:(\*|\/[^\x00-\x1f]{0,4095})|(?!root:)[a-z][a-z0-9-]{0,31}:[^\x00-\x1f]{1,4096})$/;
|
|
14
|
+
export const AGENT_MACHINE_GRANT_MS = 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/** Canonical grant shape, shared by both issuance doors and persisted-row admission.
|
|
17
|
+
* Lifetime is measured from issuance, so an old overlong row cannot become valid by ageing.
|
|
18
|
+
*/
|
|
19
|
+
export function machineGrantProblem(grant, { agent = false, lifetime = false, now = Date.now() } = {}) {
|
|
20
|
+
const { caps, targets, created_at, expires_at } = grant;
|
|
21
|
+
if (!Array.isArray(caps) || !caps.length || new Set(caps).size !== caps.length || !caps.every(cap => MACHINE_CAPS.has(cap))) return 'caps must be a non-empty set of machine capabilities';
|
|
22
|
+
if (!Array.isArray(targets) || !targets.length || targets.length > 64 || !targets.every(target => typeof target === 'string' && MACHINE_TARGET.test(target))) return 'targets must name * or <kind>:<name>';
|
|
23
|
+
if (caps.includes('operate') && !(targets.length === 1 && targets[0] === '*')) return 'operate is the whole machine: its only target is *';
|
|
24
|
+
if (targets.includes('*') && !caps.every(cap => cap === 'operate' || cap === 'view')) return 'only operate and view may target *';
|
|
25
|
+
if (!Number.isSafeInteger(expires_at) || expires_at <= now) return 'machine grant expiry must be in the future';
|
|
26
|
+
if (agent || lifetime) {
|
|
27
|
+
const maximum = agent ? AGENT_MACHINE_GRANT_MS : 90 * 24 * 60 * 60 * 1000;
|
|
28
|
+
if (!Number.isSafeInteger(created_at) || expires_at <= created_at || expires_at - created_at > maximum) return agent ? 'an agent machine delegation expires within 1 hour of issuance' : 'machine grant expiry must be within 90 days of issuance';
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export function targetsCover(patterns, target) {
|
|
13
34
|
for (const pattern of patterns) {
|
|
14
35
|
if (pattern === '*') return true;
|
|
@@ -52,7 +73,7 @@ export function admitFromGrants(claim, { custodianId, grants, now = Date.now() }
|
|
|
52
73
|
const held = [];
|
|
53
74
|
for (const id of Array.isArray(claim.grants) ? claim.grants : []) {
|
|
54
75
|
const grant = grants.get(id);
|
|
55
|
-
if (!grant || grant.
|
|
76
|
+
if (!grant || machineGrantProblem(grant, { agent: claim.agent === true, now })) continue;
|
|
56
77
|
const subjects = new Set([claim.principal, ...(Array.isArray(claim.groups) ? claim.groups : [])]);
|
|
57
78
|
if (!subjects.has(grant.subject_id)) continue;
|
|
58
79
|
held.push({ id: grant.id, caps: grant.caps, targets: grant.targets });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
package/server/http.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { permissionRoute } from './permissions.mjs';
|
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
|
|
12
12
|
import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToken, sha256 } from './wire.mjs';
|
|
13
|
-
import { machineGrantRoute, machineGrantRows, closeMachineChannels, closeChannelsFor, closeCredentialChannels } from './machine-link.mjs';
|
|
13
|
+
import { machineGrantRoute, machineGrantRows, agentMachineClaim, closeMachineChannels, closeChannelsFor, closeCredentialChannels } from './machine-link.mjs';
|
|
14
14
|
import { publicationRoute, publisherRevocation } from './publications.mjs';
|
|
15
15
|
import { exact, parseJson as parseRequestJson } from './request-json.mjs';
|
|
16
16
|
import { identityOf } from './identity.mjs';
|
|
@@ -44,7 +44,9 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
44
44
|
const auth=await authenticate(api,bearer(req.headers.authorization));res._authenticated=Boolean(auth);
|
|
45
45
|
if(auth?.installation_id){const scoped=/^\/api\/v1\/teams\/([^/]+)(\/.*)?$/.exec(path);if(scoped&&scoped[1]!==auth.team_id)fail(403,'forbidden','app token belongs to another organization');const target=scoped?.[2]??path.replace(/^\/api\/v1/,'');if(target===`/app-installations/${auth.installation_id}/delegations`&&req.method==='POST'){}else enforceAppScope(auth,req.method,target);}
|
|
46
46
|
// An agent reads identity attribution (C4: resolving a requester to sponsor a card); a link grants it nothing.
|
|
47
|
-
if(auth?.agent_credential&&!((req.method==='GET'&&/^\/api\/v1\/(?:me$|teams(?:$|\/[^/]+(?:$|\/(?:agents|sessions)(?:$|\/)|\/identity\/resolve$)))/.test(path))
|
|
47
|
+
if(auth?.agent_credential&&!((req.method==='GET'&&/^\/api\/v1\/(?:me$|teams(?:$|\/[^/]+(?:$|\/(?:agents|sessions)(?:$|\/)|\/identity\/resolve$)))/.test(path))
|
|
48
|
+
||(req.method==='GET'&&/^\/api\/v1\/teams\/[^/]+\/machines(?:\/(?!events$)[^/]+)?$/.test(path))
|
|
49
|
+
||(req.method==='POST'&&/^\/api\/v1\/teams\/[^/]+\/sessions\/[^/]+\/mail$/.test(path))))fail(403,'forbidden','agent credential is limited to identity/session reads, own-session mail and explicitly granted machines');
|
|
48
50
|
if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
|
|
49
51
|
if(req.method==='POST'&&path==='/api/v1/invitations/accept'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await acceptInvite(api,res,body,requestId);}
|
|
50
52
|
if(req.method==='POST'&&path==='/api/v1/login/volter'){checkRate(api,`login:${req.socket.remoteAddress}`,10,5);const body=await parseJson(req);return await identityLogin(api,res,body,requestId);}
|
|
@@ -376,6 +378,15 @@ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(4
|
|
|
376
378
|
// `online` is a machine that heartbeats and holds its link, so callers can reach it; `unlinked` heartbeats without one.
|
|
377
379
|
const machineJson=(m,now,api)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?(m.presence_linked===1?'online':'unlinked'):'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
|
|
378
380
|
async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
|
|
381
|
+
if(auth.agent_credential){
|
|
382
|
+
// Discovery reveals only machines this exact agent may reach; a reader gets no fleet directory by membership.
|
|
383
|
+
const ids=machineId?[{machine_id:machineId}]:await api.store.query('SELECT DISTINCT machine_id FROM machine_grants WHERE team_id=? AND subject_id=? AND expires_at>?',[auth.team_id,auth.principal_id,api.clock.nowMs()]);
|
|
384
|
+
const allowed=[];for(const item of ids)if(await agentMachineClaim(api,auth,item.machine_id))allowed.push(item.machine_id);
|
|
385
|
+
if(machineId&&!allowed.length)fail(404,'not_found','not found');
|
|
386
|
+
const rows=allowed.length?await api.store.query(`SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked FROM machines m WHERE m.team_id=? AND m.id IN (${allowed.map(()=>'?').join(',')}) ORDER BY m.name,m.id`,[auth.team_id,...allowed]):[];
|
|
387
|
+
if(machineId){if(!rows.length)fail(404,'not_found','not found');return send(res,200,result(api,auth,machineJson(rows[0],api.clock.nowMs(),api)),requestId,{ETag:`"${rows[0].revision}"`});}
|
|
388
|
+
return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs(),api)),next_cursor:null},requestId);
|
|
389
|
+
}
|
|
379
390
|
// Custodians and admins see their machines, revoked ones included; every member sees the team's active machines,
|
|
380
391
|
// each of which they can message (the team's standing mail grant, machine-link.mjs). Roots stay custodial.
|
|
381
392
|
const custody=roots?'(m.custodian_id=? OR ?=1)':'(m.custodian_id=? OR ?=1 OR m.revoked_at IS NULL)',params=[auth.team_id,auth.principal_id,isAdmin(auth)?1:0];
|
package/server/machine-link.mjs
CHANGED
|
@@ -18,20 +18,16 @@ import { appAuthentication } from './apps.mjs';
|
|
|
18
18
|
import { machineTransition } from './machine-events.mjs';
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
20
|
import { Channel } from '../channel.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { machineGrantProblem } from '../admission.mjs';
|
|
22
|
+
import { lineSplitter } from '../lines.mjs';
|
|
23
|
+
import { APP_LINEAGE, APP_LINEAGE_REVOKED, bearer } from './auth.mjs';
|
|
24
|
+
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
22
25
|
import { delegatedAccessOf, delegatedPrincipal, delegatedSessionOf, looksDelegated, surfaceCaps } from './delegated.mjs';
|
|
23
26
|
import { delegatedGrantIssuer, publicationFor } from './publications.mjs';
|
|
24
27
|
|
|
25
28
|
const LINK_PROTOCOL = 'supercode-machine-link.v1';
|
|
26
29
|
const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
|
|
27
30
|
/** The machine daemon's door vocabulary: operate (operator: harness.v1 and launch) and the participant caps its doors check. `mail` writes one message into a session there (`mail:*`, or `mail:<address>` for one session). */
|
|
28
|
-
const CAPS = new Set(['operate', 'view', 'observe', 'interact', 'control', 'files', 'ports', 'mail']);
|
|
29
|
-
// A target is `*` or `<kind>:<name>`. The server checks shape only: which kinds a machine
|
|
30
|
-
// serves is the daemon's business, and a grant on a kind it does not serve covers nothing.
|
|
31
|
-
const TARGET = /^(\*|root:(\*|\/[^\x00-\x1f]{0,4095})|(?!root:)[a-z][a-z0-9-]{0,31}:[^\x00-\x1f]{1,4096})$/;
|
|
32
|
-
// `*` is the whole machine: operate it, or list it. Anything more names the kind it reaches.
|
|
33
|
-
const WHOLE_MACHINE_CAPS = new Set(['operate', 'view']);
|
|
34
|
-
const MAX_GRANT_MS = 90 * 24 * 60 * 60 * 1000;
|
|
35
31
|
|
|
36
32
|
const links = (api) => api.machineLinks ??= new Map();
|
|
37
33
|
const channels = (api) => api.machineChannels ??= new Set();
|
|
@@ -54,11 +50,39 @@ export async function machineGrantRows(api, teamId, machineId) {
|
|
|
54
50
|
return [...rows.map(grantJson), teamMailGrant(teamId, machineId)];
|
|
55
51
|
}
|
|
56
52
|
|
|
53
|
+
/** An agent's reader token exercises only a direct, explicitly issued machine grant.
|
|
54
|
+
* Neither its sponsor, its groups nor the team's standing mail grant conveys machine access.
|
|
55
|
+
* The token and its delegation chain are checked again when a stream is opened.
|
|
56
|
+
*/
|
|
57
|
+
export async function agentMachineClaim(api, auth, machineId) {
|
|
58
|
+
if (!auth.agent_credential || auth.principal_kind !== 'agent') return null;
|
|
59
|
+
const now = api.clock.nowMs();
|
|
60
|
+
if (!await api.store.one(`SELECT 1 FROM credentials c JOIN agent_credentials a ON a.credential_id=c.id
|
|
61
|
+
JOIN memberships mem ON mem.team_id=c.team_id AND mem.principal_id=c.principal_id
|
|
62
|
+
WHERE c.id=? AND c.team_id=? AND c.principal_id=? AND c.revoked_at IS NULL
|
|
63
|
+
AND c.expires_at>? AND mem.active=1`, [auth.credential_id, auth.team_id, auth.principal_id, now])) return null;
|
|
64
|
+
if (!await api.store.one(AGENT_ACTIVE, [auth.team_id, auth.principal_id])
|
|
65
|
+
|| await api.store.one(APP_LINEAGE_REVOKED, [auth.credential_id, now])) return null;
|
|
66
|
+
const candidates = await api.store.query(`SELECT g.* FROM machine_grants g
|
|
67
|
+
JOIN machines machine ON machine.team_id=g.team_id AND machine.id=g.machine_id
|
|
68
|
+
JOIN memberships issuer ON issuer.team_id=g.team_id AND issuer.principal_id=g.issuer_id
|
|
69
|
+
WHERE g.team_id=? AND g.machine_id=? AND g.subject_id=? AND g.expires_at>?
|
|
70
|
+
AND machine.revoked_at IS NULL AND issuer.active=1 AND g.issuer_id!=g.subject_id
|
|
71
|
+
AND (g.issuer_id=machine.custodian_id OR issuer.role IN ('owner','admin'))
|
|
72
|
+
AND g.app_installation_id IS NULL ORDER BY g.id`, [auth.team_id, machineId, auth.principal_id, now]);
|
|
73
|
+
const rows = candidates.filter(row => {
|
|
74
|
+
try { return !machineGrantProblem(grantJson(row), { agent: true, now }); }
|
|
75
|
+
catch { return false; }
|
|
76
|
+
});
|
|
77
|
+
if (!rows.length) return null;
|
|
78
|
+
return { principal: auth.principal_id, principal_name: auth.display_name ?? null, groups: [], agent: true,
|
|
79
|
+
expires_at: Math.min(auth.expires_at, ...rows.map(row => row.expires_at)),
|
|
80
|
+
grants: rows.map(row => row.id), revisions: Object.fromEntries(rows.map(row => [row.id, row.revision])) };
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
/** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
|
|
58
84
|
async function claimFor(api, auth, machineId) {
|
|
59
|
-
|
|
60
|
-
// Their narrowly authorized session operations use the session door instead.
|
|
61
|
-
if(auth.agent_credential)return null;
|
|
85
|
+
if (auth.agent_credential) return agentMachineClaim(api, auth, machineId);
|
|
62
86
|
if(auth.installation_id&&auth.kind!=='delegated'&&!await appAuthentication(api,auth))return null;
|
|
63
87
|
if (auth.kind === 'delegated') {
|
|
64
88
|
const row = await api.store.one(`SELECT g.id,g.revision FROM machine_grants g JOIN app_installations i ON i.id=g.app_installation_id JOIN apps a ON a.id=i.app_id
|
|
@@ -157,18 +181,19 @@ export async function machineGrantRoute({ api, req, res, auth, machineId, grantI
|
|
|
157
181
|
}
|
|
158
182
|
if (req.method === 'POST' && !grantId) {
|
|
159
183
|
const body = await parseJson(req); exact(body, ['subject_id', 'caps', 'targets', 'expires_at']);
|
|
160
|
-
if (!Array.isArray(body.caps) || !body.caps.length || new Set(body.caps).size !== body.caps.length || !body.caps.every((cap) => CAPS.has(cap))) fail(400, 'invalid_request', `caps must be a non-empty set of ${[...CAPS].join(', ')}`);
|
|
161
|
-
if (!Array.isArray(body.targets) || !body.targets.length || body.targets.length > 64 || !body.targets.every((target) => typeof target === 'string' && TARGET.test(target))) fail(400, 'invalid_request', 'targets must name * or <kind>:<name> (for example pane:<key>, root:/path, port:<pane>/<n>, pane:*)');
|
|
162
|
-
if (body.caps.includes('operate') && !(body.targets.length === 1 && body.targets[0] === '*')) fail(400, 'invalid_request', 'operate is the whole machine: its only target is *');
|
|
163
|
-
if (body.targets.includes('*') && !body.caps.every((cap) => WHOLE_MACHINE_CAPS.has(cap))) fail(400, 'invalid_request', 'only operate and view may target *; name the kind instead (e.g. pane:*, port:*)');
|
|
164
184
|
const now = api.clock.nowMs();
|
|
165
|
-
|
|
166
|
-
const subject = typeof body.subject_id === 'string' ? await api.store.one(`SELECT 'member' kind FROM memberships WHERE team_id=? AND principal_id=? AND active=1 UNION ALL SELECT 'group' kind FROM groups_table WHERE team_id=? AND id=?`, [auth.team_id, body.subject_id, auth.team_id, body.subject_id]) : null;
|
|
185
|
+
const subject = typeof body.subject_id === 'string' ? await api.store.one(`SELECT p.kind FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1 UNION ALL SELECT 'group' kind FROM groups_table WHERE team_id=? AND id=?`, [auth.team_id, body.subject_id, auth.team_id, body.subject_id]) : null;
|
|
167
186
|
if (!subject) fail(400, 'invalid_request', 'subject must be an active member, service account or group of this team');
|
|
187
|
+
const problem = machineGrantProblem({ ...body, created_at: now }, { agent: subject.kind === 'agent', lifetime: true, now });
|
|
188
|
+
if (problem) fail(400, 'invalid_request', problem);
|
|
168
189
|
const id = api.ids.next('grant');
|
|
169
190
|
const row = { id, machine_id: machineId, subject_id: body.subject_id, caps: body.caps, targets: body.targets, issuer_id: auth.principal_id, created_at: now, expires_at: body.expires_at, revision: 1 };
|
|
170
191
|
const response = result(api, auth, row);
|
|
171
|
-
await guardedMutation(api, res, auth, [
|
|
192
|
+
await guardedMutation(api, res, auth, [
|
|
193
|
+
{ sql: 'SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', params: [auth.credential_id, now], code: 'forbidden', status: 403, message: 'grant issuer credential changed' },
|
|
194
|
+
{ sql: "SELECT 1 FROM machines machine JOIN memberships issuer ON issuer.team_id=machine.team_id WHERE machine.team_id=? AND machine.id=? AND machine.revoked_at IS NULL AND issuer.principal_id=? AND issuer.active=1 AND (machine.custodian_id=issuer.principal_id OR issuer.role IN ('owner','admin'))", params: [auth.team_id, machineId, auth.principal_id], code: 'forbidden', status: 403, message: 'machine grant authority changed' },
|
|
195
|
+
{ sql: "SELECT 1 WHERE EXISTS(SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1) OR EXISTS(SELECT 1 FROM groups_table WHERE team_id=? AND id=?)", params: [auth.team_id, body.subject_id, auth.team_id, body.subject_id], code: 'invalid_request', status: 400, message: 'grant subject changed' },
|
|
196
|
+
], [
|
|
172
197
|
{ sql: 'INSERT INTO machine_grants(team_id,id,machine_id,subject_id,caps_json,targets_json,issuer_id,created_at,expires_at,app_installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)', params: [auth.team_id, id, machineId, body.subject_id, JSON.stringify(body.caps), JSON.stringify(body.targets), auth.principal_id, now, body.expires_at, auth.installation_id??null] },
|
|
173
198
|
], { status: 201, body: response, targetId: id });
|
|
174
199
|
return send(res, 201, response, requestId, { ETag: '"1"' });
|
|
@@ -203,7 +228,6 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
203
228
|
if (door === 'channel' && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
|
|
204
229
|
const auth = await authenticate(api, presented);
|
|
205
230
|
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
206
|
-
if(auth.agent_credential)return {status:403,code:'forbidden',message:'agent session credential cannot open a machine channel'};
|
|
207
231
|
if (door === 'link') {
|
|
208
232
|
if (auth.kind !== 'machine' || auth.team_id !== teamId || auth.machine_id !== machineId) return { status: 403, code: 'forbidden', message: 'exact machine credential required' };
|
|
209
233
|
const machine = await api.store.one('SELECT incarnation_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
|
|
@@ -504,6 +528,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
504
528
|
channel = await Channel.accept(duplex, { floor: 2 });
|
|
505
529
|
} catch { closeSocket('refused'); return; }
|
|
506
530
|
const open = new Set();
|
|
531
|
+
let grantExpiry = null, grantDeadline = Infinity;
|
|
507
532
|
const entry = {
|
|
508
533
|
teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, credentialId: auth.credential_id ?? null, actors:[...(auth.act??[])], claim: null, grantIssuer: auth.grant_issuer ?? null,
|
|
509
534
|
close: (reason) => closeSocket(reason),
|
|
@@ -511,7 +536,18 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
511
536
|
};
|
|
512
537
|
channels(api).add(entry);
|
|
513
538
|
// A delegated channel ends when its token does; the viewer reopens it with a fresh one.
|
|
514
|
-
const expiry = (auth.kind === 'delegated'||(auth.installation_id&&auth.expires_at-api.clock.nowMs()<=2147483647)) ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
|
|
539
|
+
const expiry = (auth.kind === 'delegated'||auth.agent_credential||(auth.installation_id&&auth.expires_at-api.clock.nowMs()<=2147483647)) ? setTimeout(() => entry.close('token_expired'), Math.max(0, auth.expires_at - api.clock.nowMs())) : null;
|
|
540
|
+
const armGrantExpiry = claim => {
|
|
541
|
+
if (!Number.isSafeInteger(claim.expires_at) || claim.expires_at >= grantDeadline) return;
|
|
542
|
+
grantDeadline = claim.expires_at;
|
|
543
|
+
clearTimeout(grantExpiry);
|
|
544
|
+
grantExpiry = setTimeout(() => entry.close('grant_expired'), Math.max(0, grantDeadline - api.clock.nowMs()));
|
|
545
|
+
};
|
|
546
|
+
// A grant's expiry, a sponsor suspension or a delegator leaving also ends existing agent streams.
|
|
547
|
+
// Explicit revocations already close them synchronously through closeMachineChannels/closeCredentialChannels.
|
|
548
|
+
const agentRecheck = auth.agent_credential ? setInterval(() => void agentMachineClaim(api, auth, machineId)
|
|
549
|
+
.then(claim => { if (!claim || entry.claim?.grants?.some(id => !claim.grants.includes(id) || entry.claim.revisions[id] !== claim.revisions[id])) entry.close('access_revoked'); })
|
|
550
|
+
.catch(() => entry.close('access_unknown')), 5000) : null;
|
|
515
551
|
// It also ends when the workplace stops admitting the access the token was issued for (a binding revoked, a Task
|
|
516
552
|
// closed, a contract ended): an act already under way ends with its access, and the grant goes with it.
|
|
517
553
|
const recheck = auth.kind === 'delegated' && auth.token ? setInterval(() => void delegatedStillAdmits(auth).then(async (live) => {
|
|
@@ -520,11 +556,12 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
520
556
|
entry.close('access_revoked');
|
|
521
557
|
}), DELEGATED_RECHECK_MS) : null;
|
|
522
558
|
channel.on('error', () => {});
|
|
523
|
-
channel.on('close', () => { if (expiry) clearTimeout(expiry); if (recheck) clearInterval(recheck); channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
559
|
+
channel.on('close', () => { if (expiry) clearTimeout(expiry); if (grantExpiry) clearTimeout(grantExpiry); if (recheck) clearInterval(recheck); if (agentRecheck) clearInterval(agentRecheck); channels(api).delete(entry); entry.dropStreams('caller closed'); });
|
|
524
560
|
channel.on('stream', async (stream, accept, refuse) => {
|
|
525
561
|
const claim = await claimFor(api, auth, machineId).catch(() => null);
|
|
526
562
|
if (!claim) return refuse('no machine grant for this caller');
|
|
527
563
|
entry.claim = claim;
|
|
564
|
+
armGrantExpiry(claim);
|
|
528
565
|
// A machine relinks within seconds of this server restarting (a deploy drops every link): a stream waits for its
|
|
529
566
|
// link that long, and retries once on a link that closed under it, rather than refusing a message mid-relink.
|
|
530
567
|
// A line typed into a session is a person's words: a machine too loaded to answer the open in time is waited on,
|
|
@@ -551,7 +588,20 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
551
588
|
accept();
|
|
552
589
|
const pair = { close: (reason) => { if (!stream.closed) stream.close(reason); if (!upstream.closed) upstream.close(reason); } };
|
|
553
590
|
open.add(pair);
|
|
554
|
-
|
|
591
|
+
if (auth.agent_credential && stream.name === 'harness') {
|
|
592
|
+
// A held harness stream carries many calls. Opening it is not perpetual authority:
|
|
593
|
+
// check each complete request at forwarding time, including queued and split lines.
|
|
594
|
+
const lines = lineSplitter();
|
|
595
|
+
let forwarding = Promise.resolve();
|
|
596
|
+
stream.on('data', data => {
|
|
597
|
+
for (const line of lines(data)) forwarding = forwarding.then(async () => {
|
|
598
|
+
if (stream.closed || upstream.closed) return;
|
|
599
|
+
const current = await agentMachineClaim(api, auth, machineId);
|
|
600
|
+
if (!current || current.expires_at <= api.clock.nowMs() || claim.grants.some(id => !current.grants.includes(id) || current.revisions[id] !== claim.revisions[id])) return pair.close('access_revoked');
|
|
601
|
+
if (!stream.closed && !upstream.closed) upstream.write(line + '\n');
|
|
602
|
+
}).catch(() => pair.close('access_unknown'));
|
|
603
|
+
});
|
|
604
|
+
} else stream.on('data', (data) => { if (!upstream.closed) upstream.write(data); });
|
|
555
605
|
upstream.on('data', (data) => { if (!stream.closed) stream.write(data); });
|
|
556
606
|
stream.on('close', (reason) => { open.delete(pair); if (!upstream.closed) upstream.close(reason); });
|
|
557
607
|
upstream.on('close', (reason) => { open.delete(pair); if (!stream.closed) stream.close(reason); });
|
package/server/permissions.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// machine, …). Membership is a binding of a role on the Org. Teams grants allow: withholding is not granting.
|
|
4
4
|
import { isAdmin } from './auth.mjs';
|
|
5
5
|
import { exact } from './request-json.mjs';
|
|
6
|
+
import { machineGrantProblem } from '../admission.mjs';
|
|
7
|
+
import { closeMachineChannels } from './machine-link.mjs';
|
|
6
8
|
|
|
7
9
|
const fail = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
|
|
8
10
|
const stmt = (sql, ...params) => ({ sql, params });
|
|
@@ -85,6 +87,17 @@ export async function permissionRoute({ api, req, res, auth, sub, url, requestId
|
|
|
85
87
|
// a machine binding is what the machine admits: capabilities, their targets and an end (the machine channel's grant)
|
|
86
88
|
if (body.resource.kind === 'machine' && (!body.actions || !Array.isArray(body.conditions?.targets) || !body.conditions.targets.every((t) => typeof t === 'string') || body.expires_at == null))
|
|
87
89
|
fail(400, 'invalid_request', 'a machine binding names its capabilities as actions, conditions.targets and expires_at');
|
|
90
|
+
const machineGuards = [];
|
|
91
|
+
if (body.resource.kind === 'machine') {
|
|
92
|
+
const subject = body.holder.kind === 'principal'
|
|
93
|
+
? await api.store.one('SELECT p.kind FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1', [auth.team_id, body.holder.id])
|
|
94
|
+
: await api.store.one("SELECT 'group' kind FROM groups_table WHERE team_id=? AND id=?", [auth.team_id, body.holder.id]);
|
|
95
|
+
if (!subject) fail(400, 'invalid_request', 'machine binding holder must be an active principal or group of this Org');
|
|
96
|
+
const problem = machineGrantProblem({ caps: body.actions, targets: body.conditions.targets, created_at: now, expires_at: body.expires_at }, { agent: subject.kind === 'agent', lifetime: true, now });
|
|
97
|
+
if (problem) fail(400, 'invalid_request', problem);
|
|
98
|
+
machineGuards.push(guard("SELECT 1 FROM machines machine JOIN memberships issuer ON issuer.team_id=machine.team_id WHERE machine.team_id=? AND machine.id=? AND machine.revoked_at IS NULL AND issuer.principal_id=? AND issuer.active=1 AND (machine.custodian_id=issuer.principal_id OR issuer.role IN ('owner','admin'))", [auth.team_id, body.resource.id, auth.principal_id], 'machine grant authority changed'),
|
|
99
|
+
guard("SELECT 1 WHERE EXISTS(SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1) OR EXISTS(SELECT 1 FROM groups_table WHERE team_id=? AND id=?)", [auth.team_id, body.holder.id, auth.team_id, body.holder.id], 'machine grant holder changed'));
|
|
100
|
+
}
|
|
88
101
|
if (body.resource.kind === 'session' && body.role != null && !['reader', 'manager'].includes(body.role)) fail(400, 'invalid_request', 'a session binding grants reader or manager');
|
|
89
102
|
// Passing permission only narrows: a non-admin grants on a resource only what it may itself manage there.
|
|
90
103
|
const manage = isAdmin(auth) || (await decide(api, auth.team_id, auth.principal_id, `${body.resource.kind}s.access`, body.resource, now)).allowed;
|
|
@@ -94,7 +107,7 @@ export async function permissionRoute({ api, req, res, auth, sub, url, requestId
|
|
|
94
107
|
resource_id: body.resource.id, effect: 'allow', conditions_json: JSON.stringify(body.conditions ?? {}), expires_at: body.expires_at ?? null, revoked_at: null, revision: 1 };
|
|
95
108
|
const response = result(api, auth, bindingJson(row));
|
|
96
109
|
await guardedMutation(api, res, auth, [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)', [auth.credential_id, now]),
|
|
97
|
-
guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [auth.team_id, auth.principal_id])],
|
|
110
|
+
guard('SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [auth.team_id, auth.principal_id]), ...machineGuards],
|
|
98
111
|
[stmt('INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,actions_json,resource_kind,resource_id,effect,conditions_json,issuer_id,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
99
112
|
auth.team_id, id, row.holder_kind, row.holder_id, row.role, row.actions_json, row.resource_kind, row.resource_id, 'allow', row.conditions_json, auth.principal_id, now, row.expires_at),
|
|
100
113
|
stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id)], { status: 201, body: response, targetId: id });
|
|
@@ -108,6 +121,7 @@ export async function permissionRoute({ api, req, res, auth, sub, url, requestId
|
|
|
108
121
|
await guardedMutation(api, res, auth, [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL', [auth.credential_id])],
|
|
109
122
|
[stmt('UPDATE permission_bindings SET revoked_at=COALESCE(revoked_at,?),revision=revision+1 WHERE team_id=? AND id=?', now, auth.team_id, row.id),
|
|
110
123
|
stmt('UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', auth.team_id)], { status: 204, body: null, targetId: row.id });
|
|
124
|
+
if (row.resource_kind === 'machine') closeMachineChannels(api, auth.team_id, row.resource_id, { grantId: row.id });
|
|
111
125
|
send(res, 204, null, requestId); return true;
|
|
112
126
|
}
|
|
113
127
|
if (req.method === 'POST' && sub === '/permissions/decide') {
|