@volter/teams 0.1.89 → 0.2.0
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/README.md +5 -0
- package/agent-profile.mjs +15 -0
- package/client.mjs +29 -0
- package/context.mjs +34 -0
- package/event-client.mjs +8 -0
- package/http-client.d.ts +149 -0
- package/http-client.mjs +232 -0
- package/identity-record.d.ts +21 -0
- package/lines.mjs +19 -0
- package/machine-door.mjs +36 -0
- package/package.json +16 -4
- package/sender-identity.mjs +55 -0
- package/server/agent-authority.mjs +21 -0
- package/server/agent-sessions.mjs +226 -0
- package/server/agents.mjs +286 -0
- package/server/apps.mjs +35 -3
- package/server/auth.mjs +9 -1
- package/server/core.mjs +4 -3
- package/server/event-catalogue.mjs +13 -0
- package/server/http.mjs +81 -21
- package/server/identity-record.mjs +152 -0
- package/server/identity.mjs +2 -1
- package/server/machine-events.mjs +1 -1
- package/server/machine-link.mjs +107 -11
- package/server/member-mirror.mjs +147 -0
- package/server/migrations/015.mjs +72 -0
- package/server/migrations/016.mjs +28 -0
- package/server/migrations/017.mjs +60 -0
- package/server/migrations/018.mjs +82 -0
- package/server/migrations/019.mjs +20 -0
- package/server/migrations/020.mjs +9 -0
- package/server/migrations/index.mjs +7 -1
- package/server/permissions.mjs +121 -0
- package/server/published-events.mjs +145 -0
- package/server/session-snapshot.mjs +28 -0
- package/viewer/session-view.css +1 -102
- package/viewer/viewer.mjs +26 -5
- package/server/identity-orgs.mjs +0 -54
package/server/machine-link.mjs
CHANGED
|
@@ -56,6 +56,9 @@ export async function machineGrantRows(api, teamId, machineId) {
|
|
|
56
56
|
|
|
57
57
|
/** What the caller may claim on the machine right now, or null (indistinguishable from no machine). */
|
|
58
58
|
async function claimFor(api, auth, machineId) {
|
|
59
|
+
// Session credentials never inherit the team's all-session mail grant or machine grants.
|
|
60
|
+
// Their narrowly authorized session operations use the session door instead.
|
|
61
|
+
if(auth.agent_credential)return null;
|
|
59
62
|
if(auth.installation_id&&auth.kind!=='delegated'&&!await appAuthentication(api,auth))return null;
|
|
60
63
|
if (auth.kind === 'delegated') {
|
|
61
64
|
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
|
|
@@ -98,6 +101,11 @@ export function closeAppChannels(api,installationId,reason='app_uninstalled'){
|
|
|
98
101
|
for(const entry of channels(api))if(entry.installationId===installationId)entry.close(reason);
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/** A represented agent's lifetime also fences a channel whose principal is its ancestor's app. */
|
|
105
|
+
export function closeActorChannels(api,teamId,actor,reason='agent_ended'){
|
|
106
|
+
for(const entry of channels(api))if(entry.teamId===teamId&&entry.actors?.includes(actor))entry.close(reason);
|
|
107
|
+
}
|
|
108
|
+
|
|
101
109
|
/** Close the channels a revocation or machine removal leaves without authority; the next stream would be refused anyway. */
|
|
102
110
|
export function closeMachineChannels(api, teamId, machineId, { grantId = null, reason = 'access_revoked' } = {}) {
|
|
103
111
|
let closed = 0;
|
|
@@ -175,6 +183,7 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
175
183
|
if (door === 'channel' && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
|
|
176
184
|
const auth = await authenticate(api, presented);
|
|
177
185
|
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
186
|
+
if(auth.agent_credential)return {status:403,code:'forbidden',message:'agent session credential cannot open a machine channel'};
|
|
178
187
|
if (door === 'link') {
|
|
179
188
|
if (auth.kind !== 'machine' || auth.team_id !== teamId || auth.machine_id !== machineId) return { status: 403, code: 'forbidden', message: 'exact machine credential required' };
|
|
180
189
|
const machine = await api.store.one('SELECT incarnation_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [teamId, machineId]);
|
|
@@ -278,7 +287,7 @@ async function boundSessionTarget(api, teamId, access) {
|
|
|
278
287
|
/** The same delivery checks, explainable only through a session custodian's authenticated door.
|
|
279
288
|
* No token is accepted and no grant, binding or native message is created by this read.
|
|
280
289
|
*/
|
|
281
|
-
export async function sessionDeliveryResolution(api, teamId, access, diagnostics = false) {
|
|
290
|
+
export async function sessionDeliveryResolution(api, teamId, access, diagnostics = false, auth = null) {
|
|
282
291
|
const named = await api.store.query('SELECT id, canonical_id FROM sessions WHERE team_id=? AND deleted_at IS NULL AND (id=? OR id IN (SELECT session_id FROM origins WHERE team_id=? AND native_id=? AND deleted_at IS NULL))', [teamId, access.sessionRef, teamId, access.sessionRef]);
|
|
283
292
|
let reason = 'session_not_found', origins = null;
|
|
284
293
|
for (const found of named) {
|
|
@@ -294,6 +303,21 @@ export async function sessionDeliveryResolution(api, teamId, access, diagnostics
|
|
|
294
303
|
ORDER BY (o.native_id=?) DESC, o.sequence DESC LIMIT 1`, [teamId, teamId, root.id, root.id, access.sessionRef]);
|
|
295
304
|
if (origin) {
|
|
296
305
|
const target = { sessionId: root.id, harness: origin.harness, nativeId: origin.native_id, machineId: origin.machine_id };
|
|
306
|
+
if(diagnostics){
|
|
307
|
+
if(await endedNoticeTarget(api,teamId,target.sessionId))return {state:'ended',reason:'agent_session_ended',target};
|
|
308
|
+
const machine=await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[teamId,target.machineId]);
|
|
309
|
+
const claim=auth?await claimFor(api,auth,target.machineId):null;
|
|
310
|
+
const link=links(api).get(key(teamId,target.machineId));
|
|
311
|
+
if(!machine||!claim||!link||link.channel.closed)return {state:'unknown',reason:!claim?'native_liveness_not_authorized':'machine_offline',target};
|
|
312
|
+
const name=String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-');
|
|
313
|
+
try{
|
|
314
|
+
const reply=await nativeMailReply(link,claim,{op:'status',to:`sc:${name}:${target.harness}:${target.nativeId}`},35000);
|
|
315
|
+
const native=reply.code===0?reply.native:null;
|
|
316
|
+
if(!native||!['running','stopped','unavailable','unknown'].includes(native.state))return {state:'unknown',reason:'native_liveness_unavailable',target};
|
|
317
|
+
return {state:native.state==='running'?'ready':native.state,reason:native.reason??(native.state==='stopped'?'native_session_stopped':null),target,
|
|
318
|
+
native:{state:native.state,resumable:native.resumable===true},checked_at:api.clock.nowMs()};
|
|
319
|
+
}catch{return {state:'unknown',reason:'native_liveness_unavailable',target};}
|
|
320
|
+
}
|
|
297
321
|
return { state: 'ready', reason: null, target };
|
|
298
322
|
}
|
|
299
323
|
reason = 'no_live_origin';
|
|
@@ -317,6 +341,33 @@ export async function sessionDeliveryResolution(api, teamId, access, diagnostics
|
|
|
317
341
|
return { state: 'unavailable', reason, target: null, ...(origins ? { origins } : {}) };
|
|
318
342
|
}
|
|
319
343
|
|
|
344
|
+
/** The agent ownership ledger exists after C7. A process stop is distinct from its owner's terminal end. */
|
|
345
|
+
async function endedNoticeTarget(api,team,session){
|
|
346
|
+
if(!await api.store.one("SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_sessions'",[]))return false;
|
|
347
|
+
return Boolean(await api.store.one('SELECT 1 FROM agent_sessions WHERE team_id=? AND session_id=? AND ended_at IS NOT NULL',[team,session]));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function nativeMailReply(link,claim,request,ms=35000){
|
|
351
|
+
const stream=await link.channel.open('mail',{_admitted:claim,request});
|
|
352
|
+
return new Promise((resolve,reject)=>{
|
|
353
|
+
let text='',finished=false;
|
|
354
|
+
const finish=(error,result)=>{if(finished)return;finished=true;clearTimeout(timer);error?reject(error):resolve(result);};
|
|
355
|
+
const timer=setTimeout(()=>{finish(new Error('native mail reply timed out'));stream.close('timeout');},ms);
|
|
356
|
+
stream.on('data',chunk=>{text+=chunk;if(text.length>65536){finish(new Error('native mail reply exceeded its bound'));stream.close('oversized');}});
|
|
357
|
+
stream.on('close',()=>{try{finish(null,JSON.parse(text));}catch{finish(new Error('native mail reply contained no JSON receipt'));}});
|
|
358
|
+
stream.on('error',error=>finish(error));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function noticeRefusal(reply){
|
|
363
|
+
const message=reply?.text??reply?.detail??'Room notice refused';
|
|
364
|
+
if(reply?.error==='session_resume_pending')return Object.assign(new Error(message),{status:503,code:'session_resume_pending'});
|
|
365
|
+
if(reply?.error==='session_resume_unavailable')return Object.assign(new Error(message),{status:409,code:'session_resume_unavailable'});
|
|
366
|
+
if(reply?.code===3)return Object.assign(new Error(message),{status:410,code:'session_not_running'});
|
|
367
|
+
if(reply?.code===2)return Object.assign(new Error(message),{status:403,code:'native_delivery_refused'});
|
|
368
|
+
return Object.assign(new Error(message),{status:503,code:'native_delivery_unconfirmed'});
|
|
369
|
+
}
|
|
370
|
+
|
|
320
371
|
/** Close every delegated channel whose grant came from one publication (issuer from delegatedGrantIssuer): it was unpublished. */
|
|
321
372
|
export function closeDelegatedChannels(api, teamId, machineId, grantIssuer, reason = 'unpublished') {
|
|
322
373
|
let closed = 0;
|
|
@@ -371,6 +422,33 @@ async function acceptLink(api, teamId, machineId, duplex, closeSocket) {
|
|
|
371
422
|
}
|
|
372
423
|
|
|
373
424
|
const LINK_WAIT_MS = 10_000;
|
|
425
|
+
|
|
426
|
+
/** Send one already-authorized, exact native message. The receipt, rather than subprocess exit, confirms delivery. */
|
|
427
|
+
export async function deliverNativeMail(api, teamId, machineId, claim, request, authorize) {
|
|
428
|
+
const link=await linkWithin(api,key(teamId,machineId),LINK_WAIT_MS);
|
|
429
|
+
if(!link)throw Object.assign(new Error('machine is offline'),{status:503,code:'machine_offline'});
|
|
430
|
+
await authorize();
|
|
431
|
+
const stream=await link.channel.open('mail',{_admitted:claim,request});
|
|
432
|
+
return await new Promise((resolve,reject)=>{
|
|
433
|
+
let text='';
|
|
434
|
+
const timeout=setTimeout(()=>{reject(new Error('native message delivery not confirmed'));stream.close('timeout');},15000);
|
|
435
|
+
stream.on('data',chunk=>{
|
|
436
|
+
text+=chunk;
|
|
437
|
+
if(text.length>65536){clearTimeout(timeout);reject(new Error('native message receipt exceeds limit'));stream.close('receipt_limit');}
|
|
438
|
+
});
|
|
439
|
+
stream.on('close',()=>{
|
|
440
|
+
clearTimeout(timeout);
|
|
441
|
+
try{
|
|
442
|
+
if(!text.trim())throw new Error('native message returned no receipt');
|
|
443
|
+
const result=JSON.parse(text);
|
|
444
|
+
if(result.code!==0)throw new Error(result.text??result.detail??'native message refused');
|
|
445
|
+
if(result.receipt?.delivered!==true)throw new Error(result.receipt?.reason??'native message delivery not confirmed');
|
|
446
|
+
resolve({delivered:true});
|
|
447
|
+
}catch(error){reject(error);}
|
|
448
|
+
});
|
|
449
|
+
stream.on('error',error=>{clearTimeout(timeout);reject(error);});
|
|
450
|
+
});
|
|
451
|
+
}
|
|
374
452
|
/** How often an open delegated channel asks its workplace whether the token's access still holds. */
|
|
375
453
|
const DELEGATED_RECHECK_MS = 15_000;
|
|
376
454
|
|
|
@@ -407,7 +485,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
407
485
|
} catch { closeSocket('refused'); return; }
|
|
408
486
|
const open = new Set();
|
|
409
487
|
const entry = {
|
|
410
|
-
teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, claim: null, grantIssuer: auth.grant_issuer ?? null,
|
|
488
|
+
teamId: auth.team_id, machineId, installationId:auth.installation_id??null, principal: auth.principal_id, actors:[...(auth.act??[])], claim: null, grantIssuer: auth.grant_issuer ?? null,
|
|
411
489
|
close: (reason) => closeSocket(reason),
|
|
412
490
|
dropStreams: (reason) => { for (const pair of open) pair.close(reason); },
|
|
413
491
|
};
|
|
@@ -466,6 +544,7 @@ async function acceptChannel(api, auth, machineId, duplex, closeSocket) {
|
|
|
466
544
|
export async function deliverSessionRoomNotice(api, access) {
|
|
467
545
|
const target = await boundSessionTarget(api, access.teamId, access);
|
|
468
546
|
if (!target || target.harness !== access.harness) throw Object.assign(new Error('session not found'), { status: 404, code: 'not_found' });
|
|
547
|
+
if(await endedNoticeTarget(api,access.teamId,target.sessionId))throw Object.assign(new Error('the session owner has ended; nothing was sent'),{status:410,code:'agent_session_ended'});
|
|
469
548
|
const machine = await api.store.one('SELECT name FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL', [access.teamId, target.machineId]);
|
|
470
549
|
if (!machine) throw Object.assign(new Error('machine not found'), { status: 404, code: 'not_found' });
|
|
471
550
|
const machineName = String(machine.name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
@@ -480,26 +559,43 @@ export async function deliverSessionRoomNotice(api, access) {
|
|
|
480
559
|
params: [access.teamId, id, target.machineId, principal, JSON.stringify(['mail']), JSON.stringify([`mail:${to}`]), `delegated:room-notice:${digest}`, now, access.expiresAt, access.installationId] }]);
|
|
481
560
|
try {
|
|
482
561
|
const link = await linkWithin(api, key(access.teamId, target.machineId), LINK_WAIT_MS);
|
|
483
|
-
if (!link) throw new Error('machine offline; Room notice remains pending');
|
|
484
|
-
const
|
|
562
|
+
if (!link) throw Object.assign(new Error('machine offline; Room notice remains pending'),{status:503,code:'machine_offline'});
|
|
563
|
+
const claim={principal,groups:[],grants:[id],revisions:{[id]:1}};
|
|
564
|
+
let state;
|
|
565
|
+
try{state=await nativeMailReply(link,claim,{op:'status',to},35000);}
|
|
566
|
+
catch(error){
|
|
567
|
+
// A connector that predates the status door refuses the op itself. It still delivers to a running
|
|
568
|
+
// session exactly as before; only the stopped-thread resume needs the updated connector.
|
|
569
|
+
if(!/^mail refused: mail needs a send, file, list or turn request/.test(error.message))throw Object.assign(new Error('native liveness is unavailable; nothing was sent'),{status:503,code:'native_liveness_unavailable'});
|
|
570
|
+
state=null;
|
|
571
|
+
}
|
|
572
|
+
if(state&&(state.code!==0||!['running','stopped','unavailable'].includes(state.native?.state)))throw Object.assign(new Error('native liveness is unavailable; nothing was sent'),{status:503,code:'native_liveness_unavailable'});
|
|
573
|
+
if(state?.native.state==='unavailable')throw Object.assign(new Error('native session is absent; nothing was sent'),{status:410,code:'native_session_not_found'});
|
|
574
|
+
if(state?.native.state==='stopped'&&!state.native.resumable)throw Object.assign(new Error(state.native.reason??'native resume authority is unrecorded; nothing was sent'),{status:409,code:'session_resume_unavailable'});
|
|
575
|
+
// Recheck admission and the exact binding after a potentially slow native read.
|
|
576
|
+
const current=await boundSessionTarget(api,access.teamId,access);
|
|
577
|
+
if(!current||JSON.stringify(current)!==JSON.stringify(target))throw Object.assign(new Error('session binding changed; nothing was sent'),{status:409,code:'session_binding_changed'});
|
|
578
|
+
if(await endedNoticeTarget(api,access.teamId,target.sessionId))throw Object.assign(new Error('the session owner has ended; nothing was sent'),{status:410,code:'agent_session_ended'});
|
|
579
|
+
await api.store.guardedTransaction([installedGuard(access,'delegation.notices')],[]);
|
|
580
|
+
const stream = await link.channel.open('mail', { _admitted: claim,
|
|
485
581
|
request: { op: 'send', from: `sc:${machineName}:operator:room-${digest.slice(0, 24)}`, from_name: `Room ${access.roomName}`,
|
|
486
|
-
to, body: access.notice.text, id: `room-notice:${digest}`, queue: false } });
|
|
582
|
+
to, body: access.notice.text, id: `room-notice:${digest}`, queue: false,...(state?{resume_if_closed:true}:{}) } });
|
|
487
583
|
return await new Promise((resolve, reject) => {
|
|
488
584
|
let text = '';
|
|
489
|
-
const timeout = setTimeout(() => { reject(new Error('Room notice delivery not confirmed')); stream.close('timeout'); },
|
|
585
|
+
const timeout = setTimeout(() => { reject(Object.assign(new Error('Room notice delivery not confirmed'),{status:503,code:'native_delivery_unconfirmed'})); stream.close('timeout'); }, 60000);
|
|
490
586
|
// Channel data is already text; the machine writes remote-deliver's JSON reply as a string.
|
|
491
587
|
stream.on('data', chunk => { text += chunk; });
|
|
492
588
|
stream.on('close', () => {
|
|
493
589
|
clearTimeout(timeout);
|
|
494
590
|
try {
|
|
495
|
-
if (!text.trim()) throw new Error('Room notice delivery returned no receipt');
|
|
591
|
+
if (!text.trim()) throw Object.assign(new Error('Room notice delivery returned no receipt'),{status:503,code:'native_delivery_unconfirmed'});
|
|
496
592
|
const result = JSON.parse(text);
|
|
497
|
-
if (result?.code !== 0) throw
|
|
498
|
-
if (result.receipt?.delivered !== true) throw new Error(result.receipt?.reason ?? 'Room notice native delivery not confirmed');
|
|
593
|
+
if (result?.code !== 0) throw noticeRefusal(result);
|
|
594
|
+
if (result.receipt?.delivered !== true) throw Object.assign(new Error(result.receipt?.reason ?? 'Room notice native delivery not confirmed'),{status:503,code:'native_delivery_unconfirmed'});
|
|
499
595
|
resolve({ delivered: true, noticeId: access.notice.id });
|
|
500
|
-
} catch (error) { reject(error); }
|
|
596
|
+
} catch (error) { reject(error.status?error:Object.assign(new Error('Room notice delivery returned an invalid receipt'),{status:503,code:'native_delivery_unconfirmed'})); }
|
|
501
597
|
});
|
|
502
|
-
stream.on('error',
|
|
598
|
+
stream.on('error', () => { clearTimeout(timeout); reject(Object.assign(new Error('Room notice delivery channel failed'),{status:503,code:'native_delivery_unconfirmed'})); });
|
|
503
599
|
});
|
|
504
600
|
} finally { await api.store.transaction([{ sql: 'DELETE FROM machine_grants WHERE team_id=? AND id=?', params: [access.teamId, id] }]); }
|
|
505
601
|
})();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Standard org doors (mirrored ontology C6, M6; ADR 0018). Two ways a team takes members from outside:
|
|
2
|
+
// - an identity issuer's GROUP: an admin links the team to a group, and a person whose verified sign-in token carries
|
|
3
|
+
// that group in its `groups` claim joins as a member (a member removed from the team stays removed);
|
|
4
|
+
// - a LINKED SOURCE: an admin names one installed app as the team's membership source; that app's installation writes
|
|
5
|
+
// the members, each by an issuer the team installed for sign-in and a subject, and nobody else does. A subject
|
|
6
|
+
// already linked by a sign-in is seated at once; any other is a pending seat its owner's own sign-in claims, so a
|
|
7
|
+
// tenant's assertion never writes the server-wide issuer/subject link.
|
|
8
|
+
// A removal the team cannot apply (the last owner) is kept as an alarm for the admins.
|
|
9
|
+
// Joining a team gives no machine or session access.
|
|
10
|
+
import { isAdmin } from './auth.mjs';
|
|
11
|
+
import { installedIssuer } from './apps.mjs';
|
|
12
|
+
|
|
13
|
+
const text = (value, name, max = 512) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw Object.assign(new Error(`${name} is required`), { status: 400, code: 'invalid_request' }); return value.trim(); };
|
|
14
|
+
const forbid = (message) => { throw Object.assign(new Error(message), { status: 403, code: 'forbidden' }); };
|
|
15
|
+
|
|
16
|
+
/** The installation a linked team takes its membership from, or null. */
|
|
17
|
+
export async function membershipSource(api, teamId) {
|
|
18
|
+
return (await api.store.one('SELECT installation_id FROM membership_sources WHERE team_id=?', [teamId]))?.installation_id ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Refuse a membership change in a linked team made by anyone but its source. */
|
|
22
|
+
export async function requireUnlinked(api, teamId) {
|
|
23
|
+
if (await membershipSource(api, teamId)) throw Object.assign(new Error('this team takes its members from its linked source; change them there'), { status: 409, code: 'membership_linked' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a verified person's `groups` claim names a group this (unlinked) team admits from that issuer. */
|
|
27
|
+
export async function admittedByGroup(api, teamId, person) {
|
|
28
|
+
if (!Array.isArray(person.groups) || !person.groups.length || await membershipSource(api, teamId)) return false;
|
|
29
|
+
const rows = await api.store.query('SELECT group_name FROM identity_group_links WHERE team_id=? AND issuer=?', [teamId, person.issuer]);
|
|
30
|
+
return rows.some((row) => person.groups.includes(row.group_name));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A member the issuer's group admitted stays only while their token still carries a linked group: a sign-in whose
|
|
35
|
+
* token no longer does ends the membership and is refused. A member who came by invitation is unaffected.
|
|
36
|
+
*/
|
|
37
|
+
export async function groupStillAdmits(api, teamId, principalId, person) {
|
|
38
|
+
const admitted = await api.store.one('SELECT issuer FROM group_admissions WHERE team_id=? AND principal_id=?', [teamId, principalId]);
|
|
39
|
+
if (!admitted || admitted.issuer !== person.issuer) return true;
|
|
40
|
+
const rows = await api.store.query('SELECT group_name FROM identity_group_links WHERE team_id=? AND issuer=?', [teamId, person.issuer]);
|
|
41
|
+
if (rows.some((row) => (person.groups ?? []).includes(row.group_name))) return true;
|
|
42
|
+
const now = api.clock.nowMs();
|
|
43
|
+
await api.store.transaction([
|
|
44
|
+
{ sql: 'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=? AND active=1', params: [teamId, principalId] },
|
|
45
|
+
{ sql: 'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=? AND revoked_at IS NULL', params: [now, teamId, principalId] },
|
|
46
|
+
{ sql: 'DELETE FROM group_admissions WHERE team_id=? AND principal_id=?', params: [teamId, principalId] },
|
|
47
|
+
{ sql: 'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', params: [teamId] },
|
|
48
|
+
]);
|
|
49
|
+
api.events.publish(teamId, { type: 'authorization_changed', principal_id: principalId });
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function memberMirrorRoute({ api, req, res, auth, sub, requestId, parseJson, send, result }) {
|
|
54
|
+
const admin = () => { if (!isAdmin(auth) || auth.installation_id) forbid('a team admin manages its org links'); };
|
|
55
|
+
const now = api.clock.nowMs();
|
|
56
|
+
if (sub === '/identity/groups') {
|
|
57
|
+
admin();
|
|
58
|
+
if (req.method === 'GET') { send(res, 200, result(api, auth, { links: await api.store.query('SELECT issuer,group_name "group",linked_by,created_at FROM identity_group_links WHERE team_id=? ORDER BY issuer,group_name', [auth.team_id]) }), requestId); return true; }
|
|
59
|
+
const body = await parseJson(req);
|
|
60
|
+
const issuer = text(body.issuer, 'issuer'), group = text(body.group, 'group', 256);
|
|
61
|
+
if (req.method === 'POST') await api.store.transaction([{ sql: 'INSERT OR IGNORE INTO identity_group_links(team_id,issuer,group_name,linked_by,created_at) VALUES (?,?,?,?,?)', params: [auth.team_id, issuer, group, auth.principal_id, now] }]);
|
|
62
|
+
else if (req.method === 'DELETE') await api.store.transaction([{ sql: 'DELETE FROM identity_group_links WHERE team_id=? AND issuer=? AND group_name=?', params: [auth.team_id, issuer, group] }]);
|
|
63
|
+
else return false;
|
|
64
|
+
send(res, 200, result(api, auth, { issuer, group, linked: req.method === 'POST' }), requestId); return true;
|
|
65
|
+
}
|
|
66
|
+
if (sub === '/membership-source') {
|
|
67
|
+
admin();
|
|
68
|
+
if (req.method === 'GET') { send(res, 200, result(api, auth, { installation_id: await membershipSource(api, auth.team_id) }), requestId); return true; }
|
|
69
|
+
if (req.method === 'PUT') {
|
|
70
|
+
const body = await parseJson(req);
|
|
71
|
+
const installation = await api.store.one("SELECT id FROM app_installations WHERE id=? AND team_id=? AND state='active'", [text(body.installation_id, 'installation_id'), auth.team_id]);
|
|
72
|
+
if (!installation) throw Object.assign(new Error('no active installation by that id in this team'), { status: 404, code: 'not_found' });
|
|
73
|
+
await api.store.transaction([{ sql: 'INSERT INTO membership_sources(team_id,installation_id,linked_by,linked_at) VALUES (?,?,?,?) ON CONFLICT(team_id) DO UPDATE SET installation_id=excluded.installation_id,linked_by=excluded.linked_by,linked_at=excluded.linked_at', params: [auth.team_id, installation.id, auth.principal_id, now] }]);
|
|
74
|
+
send(res, 200, result(api, auth, { installation_id: installation.id }), requestId); return true;
|
|
75
|
+
}
|
|
76
|
+
if (req.method === 'DELETE') { await api.store.transaction([{ sql: 'DELETE FROM membership_sources WHERE team_id=?', params: [auth.team_id] }]); send(res, 200, result(api, auth, { installation_id: null }), requestId); return true; }
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (sub === '/membership-alarms' && req.method === 'GET') {
|
|
80
|
+
admin();
|
|
81
|
+
send(res, 200, result(api, auth, { alarms: await api.store.query('SELECT id,principal_id,issuer,subject,reason,created_at FROM membership_alarms WHERE team_id=? AND cleared_at IS NULL ORDER BY created_at', [auth.team_id]) }), requestId); return true;
|
|
82
|
+
}
|
|
83
|
+
if (sub === '/membership-seats' && req.method === 'GET') {
|
|
84
|
+
admin();
|
|
85
|
+
send(res, 200, result(api, auth, { seats: await api.store.query('SELECT issuer,subject,role,display_name,observation,created_at FROM source_seats WHERE team_id=? ORDER BY created_at', [auth.team_id]) }), requestId); return true;
|
|
86
|
+
}
|
|
87
|
+
if (sub === '/members/mirror' && req.method === 'POST') {
|
|
88
|
+
const source = await membershipSource(api, auth.team_id);
|
|
89
|
+
if (!source || auth.installation_id !== source) forbid('only the team\'s linked membership source mirrors members');
|
|
90
|
+
const body = await parseJson(req);
|
|
91
|
+
const issuer = text(body.issuer, 'issuer'), subject = text(body.subject, 'subject'), active = body.active !== false;
|
|
92
|
+
const role = body.role === 'admin' ? 'admin' : 'member';
|
|
93
|
+
// The source's own record of this person (an RH2 principal, `rh2:<id>`): linked to whoever holds the subject.
|
|
94
|
+
const observed = body.observation == null ? null : text(body.observation, 'observation');
|
|
95
|
+
if (observed !== null && !/^[a-z][a-z0-9_-]*:\S+$/.test(observed)) throw Object.assign(new Error('observation is a namespaced identifier'), { status: 400, code: 'invalid_request' });
|
|
96
|
+
// Only an issuer this team installed for sign-in names its members.
|
|
97
|
+
if (!await installedIssuer(api, issuer, 'identity.login', auth.team_id)) forbid('this team has not installed that issuer for sign-in');
|
|
98
|
+
const linked = await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?', [issuer, subject]);
|
|
99
|
+
if (!active) {
|
|
100
|
+
await api.store.transaction([{ sql: 'DELETE FROM source_seats WHERE team_id=? AND issuer=? AND subject=?', params: [auth.team_id, issuer, subject] }]);
|
|
101
|
+
if (!linked) { send(res, 200, result(api, auth, { applied: true, member: null }), requestId); return true; }
|
|
102
|
+
const member = await api.store.one('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1', [auth.team_id, linked.principal_id]);
|
|
103
|
+
if (member?.role === 'owner' && (await api.store.one("SELECT count(*) n FROM memberships WHERE team_id=? AND role='owner' AND active=1", [auth.team_id])).n <= 1) {
|
|
104
|
+
await api.store.transaction([{ sql: 'INSERT INTO membership_alarms(team_id,id,principal_id,issuer,subject,reason,created_at) VALUES (?,?,?,?,?,?,?)', params: [auth.team_id, api.ids.next('alarm'), linked.principal_id, issuer, subject, 'the source removed the team\'s last owner', now] }]);
|
|
105
|
+
throw Object.assign(new Error('the team cannot remove its last owner; an alarm was raised for its admins'), { status: 409, code: 'removal_unapplied' });
|
|
106
|
+
}
|
|
107
|
+
if (member) await api.store.transaction([
|
|
108
|
+
{ sql: 'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?', params: [auth.team_id, linked.principal_id] },
|
|
109
|
+
{ sql: 'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=? AND revoked_at IS NULL', params: [now, auth.team_id, linked.principal_id] },
|
|
110
|
+
{ sql: 'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?', params: [auth.team_id] },
|
|
111
|
+
]);
|
|
112
|
+
api.events.publish(auth.team_id, { type: 'authorization_changed', principal_id: linked.principal_id });
|
|
113
|
+
send(res, 200, result(api, auth, { applied: true, member: null }), requestId); return true;
|
|
114
|
+
}
|
|
115
|
+
if (!linked) {
|
|
116
|
+
// Nobody has signed in as this subject: the team keeps a pending seat that the person's own sign-in claims.
|
|
117
|
+
await api.store.transaction([{ sql: 'INSERT INTO source_seats(team_id,issuer,subject,role,display_name,observation,created_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(team_id,issuer,subject) DO UPDATE SET role=excluded.role,display_name=excluded.display_name,observation=excluded.observation', params: [auth.team_id, issuer, subject, role, typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 200) : null, observed, now] }]);
|
|
118
|
+
send(res, 200, result(api, auth, { applied: true, member: null, pending: true }), requestId); return true;
|
|
119
|
+
}
|
|
120
|
+
const pid = linked.principal_id;
|
|
121
|
+
const statements = observed === null ? [] : integrationLink(api, auth.team_id, observed, pid, auth.installation_id, auth.principal_id, issuer, subject, now);
|
|
122
|
+
const member = await api.store.one('SELECT active,role FROM memberships WHERE team_id=? AND principal_id=?', [auth.team_id, pid]);
|
|
123
|
+
if (!member) statements.push({ sql: 'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)', params: [auth.team_id, pid, role] });
|
|
124
|
+
else if (!member.active || (member.role !== role && member.role !== 'owner')) statements.push({ sql: 'UPDATE memberships SET active=1,role=?,revision=revision+1 WHERE team_id=? AND principal_id=?', params: [member.role === 'owner' ? 'owner' : role, auth.team_id, pid] });
|
|
125
|
+
if (statements.length) await api.store.transaction(statements);
|
|
126
|
+
send(res, 200, result(api, auth, { applied: true, member: { principal_id: pid, role: member?.role === 'owner' ? 'owner' : role } }), requestId); return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The pending seat a linked source keeps in this team for a verified person, or null (sign-in claims it). */
|
|
132
|
+
export async function sourceSeat(api, teamId, person) {
|
|
133
|
+
return api.store.one('SELECT role,display_name,observation FROM source_seats WHERE team_id=? AND issuer=? AND subject=?', [teamId, person.issuer, person.subject]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The statements that link a membership source's own record of a person (`rh2:<id>`) to the principal who signed in as
|
|
138
|
+
* the subject the source named: proven, kind integration, through the source's installation; written only while the
|
|
139
|
+
* observation has no live proven link.
|
|
140
|
+
*/
|
|
141
|
+
export function integrationLink(api, teamId, observation, principalId, installationId, by, issuer, subject, now) {
|
|
142
|
+
return [
|
|
143
|
+
{ sql: 'INSERT INTO identity_observations(team_id,id,first_seen,last_seen) VALUES (?,?,?,?) ON CONFLICT(team_id,id) DO NOTHING', params: [teamId, observation, now, now] },
|
|
144
|
+
{ sql: "INSERT INTO identity_links(team_id,id,observation_id,principal_id,classification,proof_kind,evidence_json,created_by,created_at,installation_id) SELECT ?,?,?,?,'proven','integration',?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM identity_links WHERE team_id=? AND observation_id=? AND classification='proven' AND revoked_at IS NULL)",
|
|
145
|
+
params: [teamId, api.ids.next('identity_link'), observation, principalId, JSON.stringify({ installation_id: installationId, issuer, subject }), by, now, installationId, teamId, observation] },
|
|
146
|
+
];
|
|
147
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The event and its delivery obligations share the source mutation's transaction on both hosts.
|
|
2
|
+
const now = "CAST((julianday('now')-2440587.5)*86400000 AS INTEGER)";
|
|
3
|
+
const insert = (team, kind, type, id, at, data) => `
|
|
4
|
+
INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
|
|
5
|
+
VALUES ('evt_'||lower(hex(randomblob(16))),${team},${kind},'${type}',${id},${at},'{"principal":null,"session":null,"lineage":[]}',${data});`;
|
|
6
|
+
const session = (team, id) => `json_object('id',${id},'harness',(SELECT harness FROM sessions WHERE team_id=${team} AND id=${id}),
|
|
7
|
+
'bindings',json(COALESCE((SELECT json_group_array(json(descriptor_json)) FROM bindings WHERE team_id=${team} AND session_id=${id}),'[]')))`;
|
|
8
|
+
const membership = "json_object('principal_id',NEW.principal_id,'role',NEW.role,'active',json(CASE WHEN NEW.active=1 THEN 'true' ELSE 'false' END),'revision',NEW.revision)";
|
|
9
|
+
const otherRunning = "EXISTS (SELECT 1 FROM bindings b WHERE b.team_id=NEW.team_id AND b.session_id=NEW.session_id AND b.id!=NEW.id AND json_extract(b.descriptor_json,'$.state')='running')";
|
|
10
|
+
const bindingKind = `CASE WHEN json_extract(NEW.descriptor_json,'$.state')='running' AND NOT ${otherRunning} THEN 'session.started' WHEN json_extract(NEW.descriptor_json,'$.state')='stopped' AND NOT ${otherRunning} THEN 'session.ended' ELSE 'session.changed' END`;
|
|
11
|
+
const bindingChanged = "OLD.incarnation_id!=NEW.incarnation_id OR json_extract(OLD.descriptor_json,'$.state') IS NOT json_extract(NEW.descriptor_json,'$.state') OR json_extract(OLD.descriptor_json,'$.native_runtime_id') IS NOT json_extract(NEW.descriptor_json,'$.native_runtime_id')";
|
|
12
|
+
export default `
|
|
13
|
+
CREATE TABLE event_sources (
|
|
14
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, principal_id TEXT NOT NULL, name TEXT NOT NULL,
|
|
15
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,principal_id,name),
|
|
16
|
+
FOREIGN KEY(team_id,principal_id) REFERENCES memberships(team_id,principal_id)
|
|
17
|
+
) STRICT;
|
|
18
|
+
CREATE TABLE org_events (
|
|
19
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, team_id TEXT NOT NULL REFERENCES teams(id),
|
|
20
|
+
source_id TEXT, source_revision INTEGER, kind TEXT NOT NULL, resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
|
21
|
+
actor_json TEXT NOT NULL CHECK(json_valid(actor_json)), data_json TEXT NOT NULL CHECK(json_valid(data_json)), created_at INTEGER NOT NULL
|
|
22
|
+
) STRICT;
|
|
23
|
+
CREATE INDEX org_events_replay ON org_events(team_id,sequence);
|
|
24
|
+
CREATE TABLE event_resources (
|
|
25
|
+
team_id TEXT NOT NULL, source_id TEXT NOT NULL DEFAULT '', resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
|
26
|
+
sequence INTEGER NOT NULL, source_revision INTEGER, event_json TEXT NOT NULL CHECK(json_valid(event_json)),
|
|
27
|
+
PRIMARY KEY(team_id,source_id,resource_kind,resource_id)
|
|
28
|
+
) STRICT;
|
|
29
|
+
CREATE TABLE event_retention (team_id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL) STRICT;
|
|
30
|
+
CREATE TRIGGER org_event_record AFTER INSERT ON org_events BEGIN
|
|
31
|
+
INSERT INTO event_resources(team_id,source_id,resource_kind,resource_id,sequence,source_revision,event_json)
|
|
32
|
+
VALUES (NEW.team_id,COALESCE(NEW.source_id,''),NEW.resource_kind,NEW.resource_id,NEW.sequence,NEW.source_revision,
|
|
33
|
+
json_object('id',NEW.id,'org',NEW.team_id,'at',NEW.created_at,'actor',json(NEW.actor_json),'kind',NEW.kind,
|
|
34
|
+
'resource',json_object('kind',NEW.resource_kind,'id',NEW.resource_id),'data',json(NEW.data_json)))
|
|
35
|
+
ON CONFLICT(team_id,source_id,resource_kind,resource_id) DO UPDATE SET sequence=excluded.sequence,source_revision=excluded.source_revision,event_json=excluded.event_json;
|
|
36
|
+
INSERT INTO app_webhook_deliveries(id,installation_id,event_id,body,state,next_at,created_at)
|
|
37
|
+
SELECT 'delivery_'||lower(hex(randomblob(16))),i.id,NEW.id,
|
|
38
|
+
json_object('id',NEW.id,'org',NEW.team_id,'at',NEW.created_at,'actor',json(NEW.actor_json),'kind',NEW.kind,
|
|
39
|
+
'resource',json_object('kind',NEW.resource_kind,'id',NEW.resource_id),'data',json(NEW.data_json)),
|
|
40
|
+
'pending',NEW.created_at,NEW.created_at
|
|
41
|
+
FROM app_installations i JOIN apps a ON a.id=i.app_id
|
|
42
|
+
WHERE i.team_id=NEW.team_id AND i.state='active' AND a.state='active' AND a.webhook_url IS NOT NULL
|
|
43
|
+
AND EXISTS (SELECT 1 FROM json_each(a.events_json) WHERE value=NEW.kind);
|
|
44
|
+
END;
|
|
45
|
+
CREATE TRIGGER membership_event_added AFTER INSERT ON memberships WHEN NEW.active=1 BEGIN
|
|
46
|
+
${insert('NEW.team_id', "'membership.added'", 'membership', 'NEW.principal_id', now, membership)}
|
|
47
|
+
END;
|
|
48
|
+
CREATE TRIGGER membership_event_changed AFTER UPDATE OF active,role ON memberships WHEN OLD.active!=NEW.active OR OLD.role!=NEW.role BEGIN
|
|
49
|
+
${insert('NEW.team_id', "CASE WHEN NEW.active=0 THEN 'membership.removed' WHEN OLD.active=0 THEN 'membership.added' ELSE 'membership.changed' END", 'membership', 'NEW.principal_id', now, membership)}
|
|
50
|
+
END;
|
|
51
|
+
CREATE TRIGGER session_event_capture AFTER INSERT ON session_versions BEGIN
|
|
52
|
+
${insert('NEW.team_id', "'session.changed'", 'session', 'NEW.session_id', 'NEW.created_at', session('NEW.team_id', 'NEW.session_id'))}
|
|
53
|
+
END;
|
|
54
|
+
CREATE TRIGGER session_event_binding_added AFTER INSERT ON bindings BEGIN
|
|
55
|
+
${insert('NEW.team_id', bindingKind, 'session', 'NEW.session_id', 'NEW.observed_at', session('NEW.team_id', 'NEW.session_id'))}
|
|
56
|
+
END;
|
|
57
|
+
CREATE TRIGGER session_event_binding_changed AFTER UPDATE ON bindings WHEN ${bindingChanged} BEGIN
|
|
58
|
+
${insert('NEW.team_id', bindingKind, 'session', 'NEW.session_id', 'NEW.observed_at', session('NEW.team_id', 'NEW.session_id'))}
|
|
59
|
+
END;
|
|
60
|
+
CREATE TRIGGER session_event_binding_removed AFTER DELETE ON bindings BEGIN
|
|
61
|
+
${insert('OLD.team_id', "'session.changed'", 'session', 'OLD.session_id', now, session('OLD.team_id', 'OLD.session_id'))}
|
|
62
|
+
END;
|
|
63
|
+
CREATE TRIGGER machine_event_presence AFTER INSERT ON machine_presence_events BEGIN
|
|
64
|
+
${insert('NEW.team_id', "'resource.changed'", 'machine', 'NEW.machine_id', 'NEW.created_at', "json_object('id',NEW.machine_id,'name',NEW.name,'linked',json(CASE WHEN NEW.linked=1 THEN 'true' ELSE 'false' END))")}
|
|
65
|
+
END;
|
|
66
|
+
INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
|
|
67
|
+
SELECT 'evt_'||lower(hex(randomblob(16))),m.team_id,'membership.changed','membership',m.principal_id,${now},'{"principal":null,"session":null,"lineage":[]}',json_object('principal_id',m.principal_id,'role',m.role,'active',json(CASE WHEN m.active=1 THEN 'true' ELSE 'false' END),'revision',m.revision) FROM memberships m;
|
|
68
|
+
INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
|
|
69
|
+
SELECT 'evt_'||lower(hex(randomblob(16))),s.team_id,'session.changed','session',s.id,${now},'{"principal":null,"session":null,"lineage":[]}',${session('s.team_id', 's.id')} FROM sessions s WHERE s.deleted_at IS NULL;
|
|
70
|
+
INSERT INTO org_events(id,team_id,kind,resource_kind,resource_id,created_at,actor_json,data_json)
|
|
71
|
+
SELECT 'evt_'||lower(hex(randomblob(16))),p.team_id,'resource.changed','machine',p.machine_id,p.changed_at,'{"principal":null,"session":null,"lineage":[]}',json_object('id',p.machine_id,'name',p.name,'linked',json(CASE WHEN p.linked=1 THEN 'true' ELSE 'false' END)) FROM machine_presence p;
|
|
72
|
+
`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Identity evidence is Org-owned. Declaring a person grants no membership or credential.
|
|
2
|
+
export default `
|
|
3
|
+
CREATE TABLE identity_principals (
|
|
4
|
+
team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
5
|
+
declared_by TEXT NOT NULL REFERENCES principals(id), created_at INTEGER NOT NULL,
|
|
6
|
+
PRIMARY KEY(team_id,principal_id)
|
|
7
|
+
) STRICT;
|
|
8
|
+
CREATE TABLE identity_observations (
|
|
9
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
|
|
10
|
+
PRIMARY KEY(team_id,id)
|
|
11
|
+
) STRICT;
|
|
12
|
+
CREATE TABLE identity_sightings (
|
|
13
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, observation_id TEXT NOT NULL, location TEXT NOT NULL,
|
|
14
|
+
observed_at INTEGER NOT NULL, recorded_at INTEGER NOT NULL, recorder_id TEXT NOT NULL REFERENCES principals(id),
|
|
15
|
+
name TEXT, evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
|
|
16
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,observation_id) REFERENCES identity_observations(team_id,id)
|
|
17
|
+
) STRICT;
|
|
18
|
+
CREATE INDEX identity_sightings_lookup ON identity_sightings(team_id,observation_id,recorded_at,id);
|
|
19
|
+
CREATE TABLE identity_links (
|
|
20
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, observation_id TEXT NOT NULL, principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
21
|
+
classification TEXT NOT NULL CHECK(classification IN ('proven','inferred')),
|
|
22
|
+
proof_kind TEXT NOT NULL, evidence_json TEXT NOT NULL CHECK(json_valid(evidence_json)),
|
|
23
|
+
created_by TEXT NOT NULL REFERENCES principals(id), created_at INTEGER NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
24
|
+
revoked_at INTEGER, revoked_by TEXT REFERENCES principals(id), installation_id TEXT REFERENCES app_installations(id),
|
|
25
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,observation_id) REFERENCES identity_observations(team_id,id)
|
|
26
|
+
) STRICT;
|
|
27
|
+
CREATE UNIQUE INDEX identity_one_proven ON identity_links(team_id,observation_id) WHERE classification='proven' AND revoked_at IS NULL;
|
|
28
|
+
`;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Agent identity is a principal; its assignments and intended profile live in the orchestrator.
|
|
2
|
+
import { agentSessionEventTriggers } from '../session-snapshot.mjs';
|
|
3
|
+
export default `
|
|
4
|
+
-- Replace only the column-local enum. Principal ids and every foreign key stay in place.
|
|
5
|
+
ALTER TABLE principals RENAME COLUMN kind TO previous_kind;
|
|
6
|
+
ALTER TABLE principals ADD COLUMN kind TEXT NOT NULL DEFAULT 'service' CHECK(kind IN ('user','service','agent'));
|
|
7
|
+
UPDATE principals SET kind=previous_kind;
|
|
8
|
+
ALTER TABLE principals DROP COLUMN previous_kind;
|
|
9
|
+
|
|
10
|
+
CREATE TABLE agents (
|
|
11
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL REFERENCES principals(id),
|
|
12
|
+
name TEXT NOT NULL, parent_id TEXT, custodian_id TEXT NOT NULL REFERENCES principals(id),
|
|
13
|
+
lineage_json TEXT NOT NULL CHECK(json_valid(lineage_json)), sponsor_id TEXT REFERENCES principals(id),
|
|
14
|
+
profile_ref TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('active','suspended','ended')),
|
|
15
|
+
main_session_id TEXT, created_at INTEGER NOT NULL, ended_at INTEGER, end_reason TEXT,
|
|
16
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
17
|
+
PRIMARY KEY(team_id,id), FOREIGN KEY(team_id,parent_id) REFERENCES agents(team_id,id),
|
|
18
|
+
FOREIGN KEY(team_id,main_session_id) REFERENCES sessions(team_id,id)
|
|
19
|
+
) STRICT;
|
|
20
|
+
CREATE UNIQUE INDEX live_agent_names ON agents(team_id,COALESCE(parent_id,''),name) WHERE state!='ended';
|
|
21
|
+
ALTER TABLE teams ADD COLUMN root_agent_id TEXT REFERENCES principals(id);
|
|
22
|
+
CREATE TABLE principal_sponsors (
|
|
23
|
+
team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL REFERENCES principals(id),
|
|
24
|
+
sponsor_id TEXT NOT NULL REFERENCES principals(id), set_by TEXT NOT NULL REFERENCES principals(id),
|
|
25
|
+
created_at INTEGER NOT NULL, PRIMARY KEY(team_id,principal_id)
|
|
26
|
+
) STRICT;
|
|
27
|
+
CREATE TABLE agent_sessions (
|
|
28
|
+
team_id TEXT NOT NULL, session_id TEXT NOT NULL, agent_id TEXT,
|
|
29
|
+
starter_json TEXT NOT NULL CHECK(json_valid(starter_json)),
|
|
30
|
+
profile_json TEXT NOT NULL CHECK(json_valid(profile_json)),
|
|
31
|
+
resolved_at INTEGER NOT NULL, main INTEGER NOT NULL DEFAULT 0 CHECK(main IN (0,1)),
|
|
32
|
+
ended_at INTEGER, end_reason TEXT, revision INTEGER NOT NULL DEFAULT 1,
|
|
33
|
+
PRIMARY KEY(team_id,session_id),
|
|
34
|
+
FOREIGN KEY(team_id,session_id) REFERENCES sessions(team_id,id),
|
|
35
|
+
FOREIGN KEY(team_id,agent_id) REFERENCES agents(team_id,id)
|
|
36
|
+
) STRICT;
|
|
37
|
+
CREATE TABLE session_profile_history (
|
|
38
|
+
team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
39
|
+
profile_json TEXT NOT NULL CHECK(json_valid(profile_json)), recorded_at INTEGER NOT NULL,
|
|
40
|
+
PRIMARY KEY(team_id,session_id,revision),
|
|
41
|
+
FOREIGN KEY(team_id,session_id) REFERENCES agent_sessions(team_id,session_id)
|
|
42
|
+
) STRICT;
|
|
43
|
+
CREATE TABLE agent_credentials (
|
|
44
|
+
credential_id TEXT PRIMARY KEY REFERENCES credentials(id),
|
|
45
|
+
parent_credential_id TEXT NOT NULL REFERENCES credentials(id),
|
|
46
|
+
agent_id TEXT NOT NULL REFERENCES principals(id), team_id TEXT NOT NULL REFERENCES teams(id),
|
|
47
|
+
created_at INTEGER NOT NULL
|
|
48
|
+
) STRICT;
|
|
49
|
+
CREATE UNIQUE INDEX one_agent_main ON agent_sessions(team_id,agent_id) WHERE main=1 AND ended_at IS NULL AND agent_id IS NOT NULL;
|
|
50
|
+
CREATE TABLE agent_session_closures (
|
|
51
|
+
team_id TEXT NOT NULL, id TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
52
|
+
machine_id TEXT NOT NULL, source_id TEXT NOT NULL, harness TEXT NOT NULL, native_id TEXT NOT NULL,
|
|
53
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','closed','failed','unknown')),
|
|
54
|
+
reason TEXT NOT NULL, created_at INTEGER NOT NULL, result_json TEXT, claimed_at INTEGER, finished_at INTEGER,
|
|
55
|
+
PRIMARY KEY(team_id,id), UNIQUE(team_id,agent_id,machine_id,harness,native_id),
|
|
56
|
+
FOREIGN KEY(team_id,agent_id) REFERENCES agents(team_id,id),
|
|
57
|
+
FOREIGN KEY(team_id,machine_id) REFERENCES machines(team_id,id)
|
|
58
|
+
) STRICT;
|
|
59
|
+
${agentSessionEventTriggers()}
|
|
60
|
+
`;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// One role-and-binding shape (mirrored ontology C5): roles bundle actions; a binding grants a role or actions to a
|
|
2
|
+
// principal or a group on one resource. Session and machine grants are bindings; their old names are views.
|
|
3
|
+
export default `
|
|
4
|
+
CREATE TABLE roles (
|
|
5
|
+
team_id TEXT NOT NULL REFERENCES teams(id), name TEXT NOT NULL,
|
|
6
|
+
actions_json TEXT NOT NULL CHECK(json_valid(actions_json)), builtin INTEGER NOT NULL DEFAULT 0 CHECK(builtin IN (0,1)),
|
|
7
|
+
created_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(team_id,name)
|
|
8
|
+
) STRICT;
|
|
9
|
+
INSERT INTO roles(team_id,name,actions_json,builtin)
|
|
10
|
+
SELECT t.id,r.name,r.actions,1 FROM teams t,(
|
|
11
|
+
SELECT 'owner' name,'["*"]' actions UNION ALL
|
|
12
|
+
SELECT 'admin','["*"]' UNION ALL
|
|
13
|
+
SELECT 'member','["teams.read","members.read","groups.read","sessions.read","sessions.write","machines.read","sources.read","events.read","agents.read","identity.read","identity.observe"]' UNION ALL
|
|
14
|
+
SELECT 'viewer','["teams.read","members.read","groups.read","sessions.read","machines.read","events.read","agents.read","identity.read"]' UNION ALL
|
|
15
|
+
SELECT 'reader','["sessions.read"]' UNION ALL
|
|
16
|
+
SELECT 'manager','["sessions.read","sessions.write","sessions.access"]'
|
|
17
|
+
) r;
|
|
18
|
+
CREATE TRIGGER team_builtin_roles AFTER INSERT ON teams BEGIN
|
|
19
|
+
INSERT INTO roles(team_id,name,actions_json,builtin) VALUES
|
|
20
|
+
(NEW.id,'owner','["*"]',1),(NEW.id,'admin','["*"]',1),
|
|
21
|
+
(NEW.id,'member','["teams.read","members.read","groups.read","sessions.read","sessions.write","machines.read","sources.read","events.read","agents.read","identity.read","identity.observe"]',1),
|
|
22
|
+
(NEW.id,'viewer','["teams.read","members.read","groups.read","sessions.read","machines.read","events.read","agents.read","identity.read"]',1),
|
|
23
|
+
(NEW.id,'reader','["sessions.read"]',1),(NEW.id,'manager','["sessions.read","sessions.write","sessions.access"]',1);
|
|
24
|
+
END;
|
|
25
|
+
CREATE TABLE permission_bindings (
|
|
26
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL,
|
|
27
|
+
holder_kind TEXT NOT NULL CHECK(holder_kind IN ('principal','group')), holder_id TEXT NOT NULL,
|
|
28
|
+
role TEXT, actions_json TEXT CHECK(actions_json IS NULL OR json_valid(actions_json)),
|
|
29
|
+
resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
|
30
|
+
effect TEXT NOT NULL DEFAULT 'allow' CHECK(effect IN ('allow','deny')),
|
|
31
|
+
conditions_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(conditions_json)),
|
|
32
|
+
issuer_id TEXT, created_at INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, revoked_at INTEGER,
|
|
33
|
+
revision INTEGER NOT NULL DEFAULT 1, app_installation_id TEXT,
|
|
34
|
+
CHECK(role IS NOT NULL OR actions_json IS NOT NULL),
|
|
35
|
+
PRIMARY KEY(team_id,id)
|
|
36
|
+
) STRICT;
|
|
37
|
+
CREATE INDEX permission_bindings_resource ON permission_bindings(team_id,resource_kind,resource_id,holder_id,expires_at);
|
|
38
|
+
CREATE INDEX permission_bindings_holder ON permission_bindings(team_id,holder_id);
|
|
39
|
+
CREATE INDEX permission_bindings_installation ON permission_bindings(app_installation_id);
|
|
40
|
+
CREATE UNIQUE INDEX one_session_binding ON permission_bindings(team_id,resource_id,holder_kind,holder_id)
|
|
41
|
+
WHERE resource_kind='session' AND effect='allow' AND revoked_at IS NULL;
|
|
42
|
+
INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,actions_json,resource_kind,resource_id,conditions_json,issuer_id,created_at,expires_at,revision,app_installation_id)
|
|
43
|
+
SELECT g.team_id,g.id,CASE WHEN EXISTS(SELECT 1 FROM groups_table x WHERE x.team_id=g.team_id AND x.id=g.subject_id) THEN 'group' ELSE 'principal' END,
|
|
44
|
+
g.subject_id,NULL,g.caps_json,'machine',g.machine_id,json_object('targets',json(g.targets_json)),g.issuer_id,g.created_at,g.expires_at,g.revision,g.app_installation_id
|
|
45
|
+
FROM machine_grants g;
|
|
46
|
+
INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,resource_kind,resource_id,expires_at)
|
|
47
|
+
SELECT team_id,id,subject_kind,subject_id,role,'session',session_id,expires_at FROM session_grants;
|
|
48
|
+
DROP TABLE machine_grants;
|
|
49
|
+
DROP TABLE session_grants;
|
|
50
|
+
CREATE VIEW machine_grants AS
|
|
51
|
+
SELECT team_id,id,resource_id AS machine_id,holder_id AS subject_id,actions_json AS caps_json,
|
|
52
|
+
json_extract(conditions_json,'$.targets') AS targets_json,issuer_id,created_at,expires_at,revision,app_installation_id
|
|
53
|
+
FROM permission_bindings WHERE resource_kind='machine' AND effect='allow' AND revoked_at IS NULL;
|
|
54
|
+
CREATE TRIGGER machine_grants_insert INSTEAD OF INSERT ON machine_grants BEGIN
|
|
55
|
+
INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,actions_json,resource_kind,resource_id,conditions_json,issuer_id,created_at,expires_at,revision,app_installation_id)
|
|
56
|
+
VALUES (NEW.team_id,NEW.id,CASE WHEN EXISTS(SELECT 1 FROM groups_table x WHERE x.team_id=NEW.team_id AND x.id=NEW.subject_id) THEN 'group' ELSE 'principal' END,
|
|
57
|
+
NEW.subject_id,NEW.caps_json,'machine',NEW.machine_id,json_object('targets',json(NEW.targets_json)),NEW.issuer_id,COALESCE(NEW.created_at,0),NEW.expires_at,COALESCE(NEW.revision,1),NEW.app_installation_id);
|
|
58
|
+
END;
|
|
59
|
+
CREATE TRIGGER machine_grants_update INSTEAD OF UPDATE ON machine_grants BEGIN
|
|
60
|
+
UPDATE permission_bindings SET holder_id=NEW.subject_id,actions_json=NEW.caps_json,resource_id=NEW.machine_id,
|
|
61
|
+
conditions_json=json_object('targets',json(NEW.targets_json)),issuer_id=NEW.issuer_id,expires_at=NEW.expires_at,
|
|
62
|
+
revision=NEW.revision,app_installation_id=NEW.app_installation_id
|
|
63
|
+
WHERE team_id=OLD.team_id AND id=OLD.id;
|
|
64
|
+
END;
|
|
65
|
+
CREATE TRIGGER machine_grants_delete INSTEAD OF DELETE ON machine_grants BEGIN
|
|
66
|
+
DELETE FROM permission_bindings WHERE team_id=OLD.team_id AND id=OLD.id;
|
|
67
|
+
END;
|
|
68
|
+
CREATE VIEW session_grants AS
|
|
69
|
+
SELECT team_id,id,resource_id AS session_id,holder_kind AS subject_kind,holder_id AS subject_id,role,expires_at
|
|
70
|
+
FROM permission_bindings WHERE resource_kind='session' AND effect='allow' AND revoked_at IS NULL;
|
|
71
|
+
CREATE TRIGGER session_grants_insert INSTEAD OF INSERT ON session_grants BEGIN
|
|
72
|
+
INSERT INTO permission_bindings(team_id,id,holder_kind,holder_id,role,resource_kind,resource_id,expires_at)
|
|
73
|
+
VALUES (NEW.team_id,NEW.id,NEW.subject_kind,NEW.subject_id,NEW.role,'session',NEW.session_id,NEW.expires_at);
|
|
74
|
+
END;
|
|
75
|
+
CREATE TRIGGER session_grants_update INSTEAD OF UPDATE ON session_grants BEGIN
|
|
76
|
+
UPDATE permission_bindings SET holder_kind=NEW.subject_kind,holder_id=NEW.subject_id,role=NEW.role,resource_id=NEW.session_id,expires_at=NEW.expires_at
|
|
77
|
+
WHERE team_id=OLD.team_id AND id=OLD.id;
|
|
78
|
+
END;
|
|
79
|
+
CREATE TRIGGER session_grants_delete INSTEAD OF DELETE ON session_grants BEGIN
|
|
80
|
+
DELETE FROM permission_bindings WHERE team_id=OLD.team_id AND id=OLD.id;
|
|
81
|
+
END;
|
|
82
|
+
`;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Standard org doors (mirrored ontology C6 and M6): a team admits the members of an identity issuer's group named in the
|
|
2
|
+
// sign-in token's `groups` claim; a team may take its membership from one installed app (the linked source), read-only
|
|
3
|
+
// otherwise; a removal the source asks for that the team cannot apply is kept as an alarm.
|
|
4
|
+
export default `
|
|
5
|
+
CREATE TABLE identity_group_links (
|
|
6
|
+
team_id TEXT NOT NULL REFERENCES teams(id), issuer TEXT NOT NULL, group_name TEXT NOT NULL,
|
|
7
|
+
linked_by TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(team_id,issuer,group_name)
|
|
8
|
+
) STRICT;
|
|
9
|
+
CREATE TABLE membership_sources (
|
|
10
|
+
team_id TEXT PRIMARY KEY REFERENCES teams(id), installation_id TEXT NOT NULL, linked_by TEXT NOT NULL, linked_at INTEGER NOT NULL
|
|
11
|
+
) STRICT;
|
|
12
|
+
CREATE TABLE group_admissions (
|
|
13
|
+
team_id TEXT NOT NULL REFERENCES teams(id), principal_id TEXT NOT NULL, issuer TEXT NOT NULL, admitted_at INTEGER NOT NULL,
|
|
14
|
+
PRIMARY KEY(team_id,principal_id)
|
|
15
|
+
) STRICT;
|
|
16
|
+
CREATE TABLE membership_alarms (
|
|
17
|
+
team_id TEXT NOT NULL REFERENCES teams(id), id TEXT NOT NULL, principal_id TEXT, issuer TEXT NOT NULL, subject TEXT NOT NULL,
|
|
18
|
+
reason TEXT NOT NULL, created_at INTEGER NOT NULL, cleared_at INTEGER, PRIMARY KEY(team_id,id)
|
|
19
|
+
) STRICT;
|
|
20
|
+
`;
|