@volter/teams 0.1.90 → 0.2.1
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 +19 -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 +81 -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/server/identity-orgs.mjs +0 -54
package/server/http.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
|
|
2
2
|
import { machineEventStream } from './machine-events.mjs';
|
|
3
|
+
import { publishedEventRoute } from './published-events.mjs';
|
|
4
|
+
import { identityRecordRoute, identityAuthority } from './identity-record.mjs';
|
|
5
|
+
import { AGENT_ACTIVE } from './agent-authority.mjs';
|
|
6
|
+
import { APP_LINEAGE_REVOKED } from './auth.mjs';
|
|
7
|
+
import { agentSessionRoute, machineProfileSession, authorizeAgentMail } from './agent-sessions.mjs';
|
|
8
|
+
import { agentRoute, authorizeAgentReplay, principalSponsorRoute } from './agents.mjs';
|
|
9
|
+
import { permissionRoute } from './permissions.mjs';
|
|
3
10
|
import { randomBytes } from 'node:crypto';
|
|
4
11
|
import { bearer, hashToken, canManage, isAdmin, makeToken } from './auth.mjs';
|
|
5
12
|
import { ContractError, canonicalJson, validationFailure, cursorHash, cursorToken, sha256 } from './wire.mjs';
|
|
@@ -8,7 +15,7 @@ import { publicationRoute, publisherRevocation } from './publications.mjs';
|
|
|
8
15
|
import { exact, parseJson as parseRequestJson } from './request-json.mjs';
|
|
9
16
|
import { identityOf } from './identity.mjs';
|
|
10
17
|
import { serveViewer } from './viewer.mjs';
|
|
11
|
-
import {
|
|
18
|
+
import { admittedByGroup, groupStillAdmits, integrationLink, memberMirrorRoute, membershipSource, requireUnlinked, sourceSeat } from './member-mirror.mjs';
|
|
12
19
|
|
|
13
20
|
class HttpError extends Error { constructor(status,code,message,details=null){super(message);this.status=status;this.code=code;this.details=details;} }
|
|
14
21
|
const fail=(status,code,message,details)=>{throw new HttpError(status,code,message,details)};
|
|
@@ -17,7 +24,7 @@ const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_i
|
|
|
17
24
|
function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at,actor_json) VALUES (?,?,?,?,?,?,?,?,?)',params:[record.teamId,api.ids.next('audit'),record.actorId,record.operation,targetId,status<400?'allowed':'denied',journal?{$journalRevision:true}:null,now,JSON.stringify(record.act??[])]});}return statements;}
|
|
18
25
|
async function persistSideEffects(api,res,status,body){const statements=sideEffectStatements(api,res,status,body,api.clock.nowMs());if(statements.length)await api.store.transaction(statements);}
|
|
19
26
|
function send(res,status,body,requestId,headers={}){const deliver=()=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...headers});res.end(body===null?'':JSON.stringify(body));};if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(deliver);deliver();}
|
|
20
|
-
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,[...guards,...appAuthGuards(auth,now)],all):await api.store.guardedTransaction([...guards,...appAuthGuards(auth,now)],all);res._sideEffectsCommitted=true;return result;}
|
|
27
|
+
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,[...guards,...appAuthGuards(auth,now)],all):await api.store.guardedTransaction([...guards,...appAuthGuards(auth,now)],all);res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'events_available'});return result;}
|
|
21
28
|
const requireRevision=(req,current,label='resource')=>{const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==current)fail(409,'revision_mismatch',`${label} revision changed`);return expected;};
|
|
22
29
|
const CORS={'Access-Control-Allow-Origin':'*','Access-Control-Expose-Headers':'ETag, X-Request-ID, Content-Range'};
|
|
23
30
|
const esc=(x)=>String(x).replace(/[\\%_]/g,'\\$&');
|
|
@@ -36,6 +43,8 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
36
43
|
if(api.harnessRoutes&&await api.harnessRoutes.preAuth({api,req,res,path,requestId}))return;
|
|
37
44
|
const auth=await authenticate(api,bearer(req.headers.authorization));res._authenticated=Boolean(auth);
|
|
38
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
|
+
// 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))||(req.method==='POST'&&/^\/api\/v1\/teams\/[^/]+\/sessions\/[^/]+\/mail$/.test(path))))fail(403,'forbidden','agent credential is limited to identity reads, session reads and own-session mail');
|
|
39
48
|
if(api.harnessRoutes&&await api.harnessRoutes.authed({api,req,res,auth,path,requestId}))return;
|
|
40
49
|
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);}
|
|
41
50
|
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);}
|
|
@@ -56,7 +65,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
56
65
|
if(auth.kind==='user'&&auth.team_id===null){const membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id]);if(!membership?.active)fail(401,'authentication_required','authentication required');auth.role=membership.role;auth.active=membership.active;auth.team_id=teamId;auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,auth.principal_id])).map(x=>x.group_id);}else if(auth.team_id!==teamId||!auth.active)fail(401,'authentication_required','authentication required');
|
|
57
66
|
if(req.method!=='GET')res._auditRecord={teamId:auth.team_id,actorId:auth.principal_id,act:auth.act??[],operation:`${req.method} ${path}`};
|
|
58
67
|
if(['machine','ingest'].includes(auth.kind)){
|
|
59
|
-
const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
|
|
68
|
+
const machineDoor=auth.kind==='machine'&&(sub==='/machines/events'||/^\/machines\/[^/]+\/configuration$/.test(sub)||sub===`/machines/${auth.machine_id}/session-profiles`||sub.startsWith(`/machines/${auth.machine_id}/agent-closures`)||sub===`/machines/${auth.machine_id}/heartbeat`||sub.startsWith(`/machines/${auth.machine_id}/effects`)||new RegExp(`^/machines/${auth.machine_id}/attachments/[^/]+(?:/frames)?$`).test(sub));
|
|
60
69
|
const sourceDoor=sub.startsWith(`/sources/${auth.source_id}/captures`)||sub.startsWith(`/sources/${auth.source_id}/uploads`);
|
|
61
70
|
if(!machineDoor&&!sourceDoor)fail(403,'forbidden','credential kind cannot use this operation');
|
|
62
71
|
}
|
|
@@ -64,6 +73,13 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
|
|
|
64
73
|
const intrinsicPart=req.method==='PUT'&&(/\/captures\/[^/]+\/parts\/\d+$/.test(sub)||/\/uploads\/[^/]+\/chunks\/\d+$/.test(sub)),intrinsicReplay=intrinsicPart||(req.method==='POST'&&(/^\/sources\/[^/]+\/captures$/.test(sub)||/\/captures\/[^/]+\/commit$/.test(sub)||/\/effects\/[^/]+\/(?:claim|result)$/.test(sub)));
|
|
65
74
|
if(['POST','PATCH','DELETE'].includes(req.method)&&!intrinsicPart){const key=req.headers['idempotency-key'];if(typeof key!=='string'||key.length<16||key.length>128||!/^[\x20-\x7e]+$/.test(key))fail(400,'invalid_request','Idempotency-Key must be 16..128 printable ASCII characters');if(!intrinsicReplay){const body=req.method==='DELETE'?null:await parseJson(req),route=`${req.method} ${path}`,requestDigest=sha256(canonicalJson({method:req.method,path,if_match:req.headers['if-match']??null,body})),old=await api.store.one('SELECT * FROM idempotency WHERE team_id=? AND principal_id=? AND route=? AND key=? AND expires_at>?',[auth.team_id,auth.principal_id,route,key,api.clock.nowMs()]);if(old){if(old.request_digest!==requestDigest)fail(409,'idempotency_conflict','idempotency key was reused with a different request');await authorizeReplay(api,auth,req.method,sub,body);return send(res,old.response_status,JSON.parse(old.response_json),requestId);}res._idempotencyRecord={teamId:auth.team_id,principalId:auth.principal_id,route,key,requestDigest,path};}}
|
|
66
75
|
if(await appRoute({api,req,res,auth,sub,requestId,parseJson,send,result,guardedMutation}))return;
|
|
76
|
+
if(await publishedEventRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
|
|
77
|
+
if(await agentSessionRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
|
|
78
|
+
if(await agentRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation,requireRevision}))return;
|
|
79
|
+
if(await principalSponsorRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
|
|
80
|
+
if(await permissionRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
|
|
81
|
+
if(await memberMirrorRoute({api,req,res,auth,sub,requestId,parseJson,send,result}))return;
|
|
82
|
+
if(await identityRecordRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}))return;
|
|
67
83
|
if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
|
|
68
84
|
if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
|
|
69
85
|
if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
|
|
@@ -98,6 +114,18 @@ function redactSecretReplay(path,body){if(body===null||typeof body!=='object')re
|
|
|
98
114
|
async function authorizeReplay(api,auth,method,sub,body){
|
|
99
115
|
const now=api.clock.nowMs();
|
|
100
116
|
let match;
|
|
117
|
+
if((match=/^\/sessions\/([^/]+)\/mail$/.exec(sub))){await authorizeAgentMail(api,auth,decodeURIComponent(match[1]));return;}
|
|
118
|
+
if((match=/^\/machines\/([^/]+)\/session-profiles$/.exec(sub))){if(auth.kind!=='machine'||auth.machine_id!==match[1])fail(403,'forbidden','machine profile authority changed');await machineProfileSession(api,auth,body?.session);return;}
|
|
119
|
+
if((match=/^\/sessions\/([^/]+)\/agent$/.exec(sub))){const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:decodeURIComponent(match[1]),now});if(session.custodian_id!==auth.principal_id||auth.installation_id||auth.app_grants!=null)fail(403,'forbidden','session custody changed');return;}
|
|
120
|
+
if(sub==='/agents'||sub.startsWith('/agents/')){await authorizeAgentReplay(api,auth,sub,body);return;}
|
|
121
|
+
if(sub.startsWith('/identity/')) {
|
|
122
|
+
identityAuthority(auth,true,body?.classification==='proven');
|
|
123
|
+
if(sub==='/identity/principals'&&(!isAdmin(auth)||auth.installation_id))fail(403,'forbidden','principal declaration authority changed');
|
|
124
|
+
if(method==='DELETE') { const row=await api.store.one('SELECT created_by FROM identity_links WHERE team_id=? AND id=?',[auth.team_id,sub.split('/').at(-1)]); if(!row||(!(isAdmin(auth)&&!auth.installation_id)&&row.created_by!==auth.principal_id))fail(403,'forbidden','identity link authority changed'); }
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if(sub==='/event-sources'){if(!isAdmin(auth)&&!(auth.installation_id&&auth.app_actions.includes('events.publish')))fail(403,'forbidden','event source authorization changed');return;}
|
|
128
|
+
if((match=/^\/event-sources\/([^/]+)\/events$/.exec(sub))){if(!await api.store.one('SELECT 1 FROM event_sources WHERE team_id=? AND id=? AND principal_id=?',[auth.team_id,match[1],auth.principal_id]))fail(404,'not_found','event source not found');return;}
|
|
101
129
|
if(sub.startsWith('/apps')||sub.startsWith('/app-installations')){if(auth.installation_id&&sub===`/app-installations/${auth.installation_id}/delegations`&&method==='POST')return;if(!isAdmin(auth)||auth.installation_id)fail(403,'forbidden','application admin authorization changed');return;}
|
|
102
130
|
if((match=/^\/sources\/([^/]+)\/captures$/.exec(sub))){const source=await api.store.one('SELECT state,machine_id,visibility FROM sources WHERE team_id=? AND id=?',[auth.team_id,match[1]]);if(!source||source.state!=='active'||auth.source_id!==match[1])fail(403,'forbidden','source authorization changed');if(auth.kind==='machine'){const rule=await api.store.one("SELECT 1 ok FROM machines m JOIN sync_rules r ON r.team_id=m.team_id AND r.machine_id=m.id WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL AND r.id=? AND r.source_id=? AND r.state='active' AND r.revision=? AND r.visibility=?",[auth.team_id,auth.machine_id,body?.rule_id,match[1],body?.rule_revision,body?.visibility]);if(!rule)fail(403,'forbidden','sync rule authorization changed');}else if(auth.kind!=='ingest'||body?.visibility!==source.visibility)fail(403,'forbidden','source policy changed');return;}
|
|
103
131
|
if((match=/^\/machines\/([^/]+)\/heartbeat$/.exec(sub))&&auth.kind==='machine'){const machine=await api.store.one('SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,match[1]]);if(!machine||auth.machine_id!==match[1])fail(403,'forbidden','machine authorization changed');return;}
|
|
@@ -114,7 +142,17 @@ async function authorizeReplay(api,auth,method,sub,body){
|
|
|
114
142
|
if(sub==='/correlations'&&method==='POST'){if(auth.role==='viewer')fail(403,'forbidden','correlation authorization changed');return;}
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
export async function authenticate(api,token){
|
|
145
|
+
export async function authenticate(api,token){
|
|
146
|
+
if(!token)return null;const digest=hashToken(token);
|
|
147
|
+
const auth=await api.store.one(`SELECT c.id credential_id,c.expires_at,c.kind,c.team_id,c.source_id,c.machine_id,c.principal_id,p.kind principal_kind,p.display_name,m.role,m.active FROM credentials c JOIN principals p ON p.id=c.principal_id LEFT JOIN memberships m ON m.team_id=c.team_id AND m.principal_id=c.principal_id WHERE c.token_hash=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?)`,[digest,api.clock.nowMs()]);
|
|
148
|
+
if(auth?.team_id)auth.group_ids=(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,auth.principal_id])).map(x=>x.group_id);
|
|
149
|
+
if(auth?.principal_kind==='agent'){
|
|
150
|
+
if(!await api.store.one(AGENT_ACTIVE,[auth.team_id,auth.principal_id])||await api.store.one(APP_LINEAGE_REVOKED,[auth.credential_id,api.clock.nowMs()]))return null;
|
|
151
|
+
auth.agent_credential=Boolean(await api.store.one('SELECT 1 FROM agent_credentials WHERE credential_id=?',[auth.credential_id]));
|
|
152
|
+
|
|
153
|
+
}
|
|
154
|
+
return await appAuthentication(api,auth);
|
|
155
|
+
}
|
|
118
156
|
async function grants(api,team,id){return api.store.query('SELECT * FROM session_grants WHERE team_id=? AND session_id=?',[team,id]);}
|
|
119
157
|
const authGuards=(auth,_admissionNow)=>{
|
|
120
158
|
const now=auth.clock?.nowMs()??_admissionNow;
|
|
@@ -146,11 +184,11 @@ async function revokeCredential(api,req,res,auth,id,requestId){
|
|
|
146
184
|
if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential kind cannot use this operation');const credential=await api.store.one('SELECT * FROM credentials WHERE id=? AND revoked_at IS NULL',[id]);if(!credential)fail(404,'not_found','not found');const globalUser=credential.kind==='user'&&credential.team_id===null;if(globalUser?credential.principal_id!==auth.principal_id:credential.team_id!==auth.team_id||credential.principal_id!==auth.principal_id&&!isAdmin(auth))fail(404,'not_found','not found');
|
|
147
185
|
const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM credentials c WHERE c.id=? AND c.revoked_at IS NULL AND ((c.kind='user' AND c.team_id IS NULL AND c.principal_id=?) OR (c.team_id=? AND (c.principal_id=? OR ?=1))) AND (c.id!=? OR c.kind!='user' OR c.team_id IS NOT NULL OR NOT EXISTS (SELECT 1 FROM memberships m WHERE m.principal_id=c.principal_id AND m.role='owner' AND m.active=1) OR EXISTS (SELECT 1 FROM credentials other WHERE other.principal_id=c.principal_id AND other.kind='user' AND other.team_id IS NULL AND other.id!=c.id AND other.revoked_at IS NULL AND (other.expires_at IS NULL OR other.expires_at>?)))",params:[id,auth.principal_id,auth.team_id,auth.principal_id,isAdmin(auth)?1:0,auth.credential_id,now],code:'conflict',status:409,message:'credential changed or would lock out owner'}];await guardedMutation(api,res,auth,guards,[{sql:'UPDATE credentials SET revoked_at=? WHERE id=?',params:[now,id]}],{status:204,body:null,targetId:id});send(res,204,null,requestId);
|
|
148
186
|
}
|
|
149
|
-
async function createInvitation(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const body=await parseJson(req);exact(body,['display_name','role','expires_in_ms']);if(!['admin','member','viewer'].includes(body.role)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>604800000)fail(400,'invalid_request','invalid invitation');const pid=api.ids.next('user'),iid=api.ids.next('invitation'),secret=`sci_${iid}.${randomBytes(32).toString('base64url')}`,now=api.clock.nowMs(),exp=now+body.expires_in_ms,response=result(api,auth,{id:iid,role:body.role,expires_at:exp,enrollment_code:secret});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',body.display_name]},{sql:'INSERT INTO invitations(team_id,id,principal_id,secret_hash,role,expires_at) VALUES (?,?,?,?,?,?)',params:[auth.team_id,iid,pid,hashToken(secret),body.role,exp]}],{status:201,body:response,targetId:iid});send(res,201,response,requestId);}
|
|
187
|
+
async function createInvitation(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');await requireUnlinked(api,auth.team_id);const body=await parseJson(req);exact(body,['display_name','role','expires_in_ms']);if(!['admin','member','viewer'].includes(body.role)||!Number.isSafeInteger(body.expires_in_ms)||body.expires_in_ms<1||body.expires_in_ms>604800000)fail(400,'invalid_request','invalid invitation');const pid=api.ids.next('user'),iid=api.ids.next('invitation'),secret=`sci_${iid}.${randomBytes(32).toString('base64url')}`,now=api.clock.nowMs(),exp=now+body.expires_in_ms,response=result(api,auth,{id:iid,role:body.role,expires_at:exp,enrollment_code:secret});await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',body.display_name]},{sql:'INSERT INTO invitations(team_id,id,principal_id,secret_hash,role,expires_at) VALUES (?,?,?,?,?,?)',params:[auth.team_id,iid,pid,hashToken(secret),body.role,exp]}],{status:201,body:response,targetId:iid});send(res,201,response,requestId);}
|
|
150
188
|
async function acceptInvite(api,res,body,requestId){exact(body,['enrollment_code','access_token']);
|
|
151
189
|
// Accepted with an identity service's token, the new member is linked to it from the start.
|
|
152
190
|
const person=body.access_token===undefined?null:await identityOf(api,body.access_token);if(body.access_token!==undefined&&!person)fail(401,'authentication_required','the identity token did not verify');const digest=hashToken(body.enrollment_code??'');const inv=await api.store.one('SELECT i.*,p.display_name,t.name team_name,t.revision team_revision FROM invitations i JOIN principals p ON p.id=i.principal_id JOIN teams t ON t.id=i.team_id WHERE i.secret_hash=?',[digest]),now=api.clock.nowMs();if(!inv||inv.consumed_at!=null||inv.revoked_at!=null||inv.expires_at<=now)fail(401,'authentication_required','invalid invitation');const installation=person?await installedIssuer(api,person.issuer,'identity.login',inv.team_id):null;if(person&&!installation)fail(401,'authentication_required','identity app is not installed in the invitation organization');const cid=api.ids.next('credential'),token=makeToken(cid);await api.store.guardedTransaction([{sql:'SELECT 1 ok FROM invitations WHERE id=? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?',params:[inv.id,now],code:'authentication_required',status:401,message:'invalid invitation'},...(installation?[{sql:"SELECT 1 FROM app_installations WHERE id=? AND state='active'",params:[installation.id],code:'authentication_required',status:401,message:'identity app was uninstalled'}]:[])],[{sql:'UPDATE invitations SET consumed_at=? WHERE id=?',params:[now,inv.id]},{sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[inv.team_id,inv.principal_id,inv.role]},...(person?[{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,inv.principal_id,now]}]:[]),{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),inv.principal_id,'user',installation?inv.team_id:null,now+2592000000,now]},...(installation?[{sql:'INSERT INTO identity_app_credentials(credential_id,installation_id) VALUES (?,?)',params:[cid,installation.id]}]:[]),{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[inv.team_id]}]);send(res,201,{principal:{id:inv.principal_id,kind:'user',display_name:inv.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:inv.team_id,name:inv.team_name,revision:inv.team_revision}},requestId);}
|
|
153
|
-
async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const published=publisherRevocation(api,auth.team_id,pid),now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]},published.statement];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});await published.closeChannels();send(res,204,null,requestId);}
|
|
191
|
+
async function removeMember(api,req,res,auth,pid,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');await requireUnlinked(api,auth.team_id);const target=await api.store.one('SELECT * FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[auth.team_id,pid]);if(!target)fail(404,'not_found','not found');requireRevision(req,target.revision,'membership');if(target.role==='owner'&&auth.role!=='owner')fail(403,'forbidden','only owners may remove owners');const published=publisherRevocation(api,auth.team_id,pid),now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:"SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1 AND revision=? AND (role!='owner' OR (SELECT count(*) FROM memberships WHERE team_id=? AND role='owner' AND active=1)>1)",params:[auth.team_id,pid,target.revision,auth.team_id],code:'conflict',status:409,message:'membership changed or last owner would be removed'}],statements=[{sql:'UPDATE memberships SET active=0,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[auth.team_id,pid]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND principal_id=?',params:[now,auth.team_id,pid]},{sql:"UPDATE sources SET state='paused',revision=revision+1 WHERE team_id=? AND custodian_id=? AND state='active'",params:[auth.team_id,pid]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]},published.statement];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:pid});api.events.publish(auth.team_id,{type:'authorization_changed',principal_id:pid});closeChannelsFor(api,auth.team_id,{principalId:pid,reason:'membership_removed'});await published.closeChannels();send(res,204,null,requestId);}
|
|
154
192
|
/** Federation (P02): a verified identity token is a member's sign-in, answered with the same 30-day credential an invitation gives. */
|
|
155
193
|
async function identityLogin(api,res,body,requestId){
|
|
156
194
|
exact(body,['access_token','team_id']);if(body.team_id!==undefined&&typeof body.team_id!=='string')fail(400,'invalid_request','team_id must be a string');
|
|
@@ -160,16 +198,19 @@ async function identityLogin(api,res,body,requestId){
|
|
|
160
198
|
if(body.team_id===undefined)body.team_id=await teamOfPerson(api,person);
|
|
161
199
|
const memberRow=()=>api.store.one('SELECT p.id principal_id,p.display_name,t.id team_id,t.name team_name,t.revision team_revision FROM principal_identities i JOIN principals p ON p.id=i.principal_id JOIN memberships m ON m.principal_id=p.id AND m.active=1 JOIN teams t ON t.id=m.team_id WHERE i.issuer=? AND i.subject=? AND (t.id=? OR t.name=?)',[person.issuer,person.subject,body.team_id,body.team_id]);
|
|
162
200
|
let row=await memberRow();
|
|
163
|
-
// A team linked to
|
|
164
|
-
//
|
|
165
|
-
if(!row)row=await
|
|
201
|
+
// A team linked to a group of this issuer admits the people whose verified token names it in its `groups` claim
|
|
202
|
+
// (member-mirror.mjs): they join as members, exactly as an accepted invitation would add them.
|
|
203
|
+
if(!row)row=await joinThroughGroup(api,person,body.team_id)&&await memberRow();
|
|
204
|
+
// A seat the team's linked source keeps for this subject is claimed by the person's own verified sign-in.
|
|
205
|
+
if(!row)row=await joinThroughSource(api,person,body.team_id)&&await memberRow();
|
|
166
206
|
if(!row)fail(401,'authentication_required','no member of this team is linked to that identity; accept an invitation with it, or link it while signed in');
|
|
207
|
+
if(!await groupStillAdmits(api,row.team_id,row.principal_id,person))fail(401,'authentication_required','the group that admitted you to this team no longer lists you');
|
|
167
208
|
const installation=await installedIssuer(api,person.issuer,'identity.login',row.team_id);if(!installation)fail(401,'authentication_required','identity app is not installed in this organization');
|
|
168
209
|
const now=api.clock.nowMs(),cid=api.ids.next('credential'),token=makeToken(cid);
|
|
169
210
|
await api.store.guardedTransaction([{sql:"SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id WHERE i.id=? AND i.state='active' AND a.state='active'",params:[installation.id],code:'authentication_required',status:401,message:'identity app was uninstalled'}],[{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?)',params:[cid,hashToken(token),row.principal_id,'user',row.team_id,now+2592000000,now]},{sql:'INSERT INTO identity_app_credentials(credential_id,installation_id) VALUES (?,?)',params:[cid,installation.id]}]);
|
|
170
211
|
send(res,201,{principal:{id:row.principal_id,kind:'user',display_name:row.display_name},credential:{token,kind:'user',expires_at:now+2592000000},team:{id:row.team_id,name:row.team_name,revision:row.team_revision}},requestId);
|
|
171
212
|
}
|
|
172
|
-
/** The one team a signed-in person belongs to, as a member or through a linked
|
|
213
|
+
/** The one team a signed-in person belongs to, as a member or through a linked group. */
|
|
173
214
|
async function teamOfPerson(api,person){
|
|
174
215
|
// A member signs into their own team without the organizations door being asked (or being up).
|
|
175
216
|
const member=await teamsOfMember(api,person),teams=member.length?member:(await teamsOfPerson(api,person)).linked;
|
|
@@ -177,14 +218,13 @@ async function teamOfPerson(api,person){
|
|
|
177
218
|
if(!teams.length)fail(401,'authentication_required','you are not a member of a team on this server; ask a teammate for an invitation');
|
|
178
219
|
fail(409,'conflict',`you belong to several teams (${teams.map(t=>t.name).join(', ')}); name one with --team`);
|
|
179
220
|
}
|
|
180
|
-
/** The teams a signed-in person may sign in to: those they are a member of, and those linked to
|
|
181
|
-
*
|
|
221
|
+
/** The teams a signed-in person may sign in to: those they are a member of, and those linked to a group their token
|
|
222
|
+
* names that they have never left (a member removed from a team stays removed; joinThroughGroup). */
|
|
182
223
|
async function teamsOfPerson(api,person){
|
|
183
224
|
const member=await teamsOfMember(api,person);
|
|
184
225
|
const ever=new Set((await api.store.query('SELECT m.team_id FROM principal_identities i JOIN memberships m ON m.principal_id=i.principal_id WHERE i.issuer=? AND i.subject=?',[person.issuer,person.subject])).map(row=>row.team_id));
|
|
185
226
|
const linked=[];
|
|
186
|
-
|
|
187
|
-
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){if(ever.has(team.id)||!await installedIssuer(api,person.issuer,'identity.login',team.id))continue;const slug=linkedOrganization(api,team);if(slug&&await inOrganization({...api,identityIssuer:person.issuer},person.subject,slug).catch(()=>false))linked.push(team);}
|
|
227
|
+
for(const team of await api.store.query('SELECT id,name FROM teams ORDER BY name',[])){if(!await installedIssuer(api,person.issuer,'identity.login',team.id))continue;if(await sourceSeat(api,team.id,person)||!ever.has(team.id)&&await admittedByGroup(api,team.id,person))linked.push(team);}
|
|
188
228
|
return {member,linked};
|
|
189
229
|
}
|
|
190
230
|
/** The teams a signed-in person is an active member of, with their role. */
|
|
@@ -200,19 +240,39 @@ async function identityTeams(api,res,body,requestId){
|
|
|
200
240
|
send(res,200,{teams:[...member.map(t=>({id:t.id,name:t.name,role:t.role,joined:true})),...linked.map(t=>({id:t.id,name:t.name,role:null,joined:false}))]},requestId);
|
|
201
241
|
}
|
|
202
242
|
/** Make `person` a member of team `team` when the team is linked to an organization they belong to; true if joined. */
|
|
203
|
-
async function
|
|
243
|
+
async function joinThroughGroup(api,person,team){
|
|
204
244
|
const found=await api.store.one('SELECT id,name FROM teams WHERE id=? OR name=?',[team,team]);if(!found||!await installedIssuer(api,person.issuer,'identity.login',found.id))return false;
|
|
205
|
-
|
|
206
|
-
if(!await inOrganization({...api,identityIssuer:person.issuer},person.subject,slug))return false;
|
|
245
|
+
if(!await admittedByGroup(api,found.id,person))return false;
|
|
207
246
|
const now=api.clock.nowMs(),linked=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[person.issuer,person.subject]);
|
|
208
247
|
const pid=linked?.principal_id??api.ids.next('user');
|
|
209
248
|
const statements=[];
|
|
210
249
|
if(!linked)statements.push({sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',person.name??person.email??person.subject]},{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,pid,now]});
|
|
211
250
|
const member=await api.store.one('SELECT active FROM memberships WHERE team_id=? AND principal_id=?',[found.id,pid]);
|
|
212
251
|
if(member?.active)return true;
|
|
213
|
-
// A member removed from the team stays removed: the
|
|
252
|
+
// A member removed from the team stays removed: the group does not bring them back.
|
|
214
253
|
if(member)return false;
|
|
215
|
-
statements.push({sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[found.id,pid,'member']}
|
|
254
|
+
statements.push({sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[found.id,pid,'member']},
|
|
255
|
+
{sql:'INSERT OR REPLACE INTO group_admissions(team_id,principal_id,issuer,admitted_at) VALUES (?,?,?,?)',params:[found.id,pid,person.issuer,now]});
|
|
256
|
+
await api.store.transaction(statements);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The person claims the seat the team's linked source keeps for their subject: their verified sign-in, not the source,
|
|
261
|
+
* writes the issuer/subject link, and the seat becomes a membership with the source's role.
|
|
262
|
+
*/
|
|
263
|
+
async function joinThroughSource(api,person,team){
|
|
264
|
+
const found=await api.store.one('SELECT id FROM teams WHERE id=? OR name=?',[team,team]);if(!found||!await installedIssuer(api,person.issuer,'identity.login',found.id))return false;
|
|
265
|
+
const seat=await sourceSeat(api,found.id,person);if(!seat)return false;
|
|
266
|
+
const now=api.clock.nowMs(),linked=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[person.issuer,person.subject]);
|
|
267
|
+
const pid=linked?.principal_id??api.ids.next('user');
|
|
268
|
+
const statements=[];
|
|
269
|
+
if(!linked)statements.push({sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[pid,'user',seat.display_name??person.name??person.email??person.subject]},{sql:'INSERT INTO principal_identities(issuer,subject,principal_id,linked_at) VALUES (?,?,?,?)',params:[person.issuer,person.subject,pid,now]});
|
|
270
|
+
const member=await api.store.one('SELECT role FROM memberships WHERE team_id=? AND principal_id=?',[found.id,pid]);
|
|
271
|
+
if(!member)statements.push({sql:'INSERT INTO memberships(team_id,principal_id,role) VALUES (?,?,?)',params:[found.id,pid,seat.role]});
|
|
272
|
+
else statements.push({sql:'UPDATE memberships SET active=1,role=?,revision=revision+1 WHERE team_id=? AND principal_id=?',params:[member.role==='owner'?'owner':seat.role,found.id,pid]});
|
|
273
|
+
statements.push({sql:'DELETE FROM source_seats WHERE team_id=? AND issuer=? AND subject=?',params:[found.id,person.issuer,person.subject]});
|
|
274
|
+
// The source's record of this person (its RH2 principal) is now linked to the principal that signed in.
|
|
275
|
+
if(seat.observation){const source=await membershipSource(api,found.id);if(source){const installer=await api.store.one('SELECT principal_id FROM app_installations WHERE id=?',[source]);statements.push(...integrationLink(api,found.id,seat.observation,pid,source,installer?.principal_id??pid,person.issuer,person.subject,now));}}
|
|
216
276
|
await api.store.transaction(statements);
|
|
217
277
|
return true;
|
|
218
278
|
}
|
|
@@ -234,7 +294,7 @@ async function collectionPage(api,auth,url,name,items){
|
|
|
234
294
|
const page=remaining.slice(0,limit),rest=remaining.slice(limit);let next=null;if(rest.length){next=cursorToken();const team=await api.store.one('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[auth.team_id]);await api.store.exec('INSERT INTO cursors(id,team_id,principal_id,kind,query_json,auth_epoch,revision,snapshot_json,expires_at) VALUES (?,?,?,?,?,?,?,?,?)',[cursorHash(next),auth.team_id,auth.principal_id,'list',JSON.stringify(normalized),team.auth_epoch,team.journal_revision,JSON.stringify(rest),api.clock.nowMs()+900000]);}return{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:page,next_cursor:next};
|
|
235
295
|
}
|
|
236
296
|
async function listMembers(api,res,auth,url,requestId){const rows=auth.role==='viewer'?await api.store.query('SELECT m.*,p.kind,p.display_name 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,auth.principal_id]):await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.active=1 ORDER BY p.display_name,m.principal_id',[auth.team_id]);send(res,200,await collectionPage(api,auth,url,'members',rows.map(membershipJson)),requestId);}
|
|
237
|
-
async function updateMember(api,req,res,auth,principalId,requestId){
|
|
297
|
+
async function updateMember(api,req,res,auth,principalId,requestId){await requireUnlinked(api,auth.team_id);
|
|
238
298
|
if(!isAdmin(auth))fail(403,'forbidden','forbidden');
|
|
239
299
|
const body=await parseJson(req);exact(body,['role']);if(!['owner','admin','member','viewer'].includes(body.role))fail(400,'invalid_request','invalid team role');
|
|
240
300
|
const target=await api.store.one('SELECT m.*,p.kind,p.display_name 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,principalId]);if(!target)fail(404,'not_found','not found');
|
|
@@ -293,7 +353,7 @@ async function revokeMachine(api,req,res,auth,machineId,requestId){
|
|
|
293
353
|
}
|
|
294
354
|
/** This machine's sessions someone is watching live (an open follow stream), as {harness, native_id}. */
|
|
295
355
|
async function watchedFor(api,teamId,machineId){const ids=[...(api.watchers?.keys()??[])].filter(key=>key.startsWith(`${teamId}:`)).map(key=>key.slice(teamId.length+1)).slice(0,90);if(!ids.length)return [];return (await api.store.query(`SELECT o.harness,o.native_id FROM origins o JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id WHERE o.team_id=? AND s.machine_id=? AND o.session_id IN (${ids.map(()=>'?').join(',')})`,[teamId,machineId,...ids])).map(({harness,native_id})=>({harness,native_id}));}
|
|
296
|
-
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
|
|
356
|
+
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,JSON.stringify(b.bindings.map(binding=>binding.id)),auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
|
|
297
357
|
if(Array.isArray(b.activity)){const source=await api.store.one("SELECT id FROM sources WHERE team_id=? AND machine_id=? AND kind='connector'",[auth.team_id,machineId]);if(source){statements.push({sql:'DELETE FROM session_activity WHERE team_id=? AND source_id=?',params:[auth.team_id,source.id]});for(const item of b.activity){exact(item,['harness','native_id','turn','since'],'activity');if(typeof item.harness!=='string'||!item.harness||item.harness.length>64||typeof item.native_id!=='string'||!item.native_id||item.native_id.length>256||typeof item.turn!=='string'||!item.turn||item.turn.length>32||(item.since!=null&&!Number.isSafeInteger(item.since)))fail(400,'invalid_request','invalid activity');statements.push({sql:'INSERT OR REPLACE INTO session_activity(team_id,source_id,harness,native_id,turn,since,observed_at) VALUES (?,?,?,?,?,?,?)',params:[auth.team_id,source.id,item.harness,item.native_id,item.turn,item.since??null,now]});}}}
|
|
298
358
|
for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}// A binding is stored against the session its native id names; one naming no session this source published is
|
|
299
359
|
// said back (unbound_bindings), never dropped in silence.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Names are observations. Only explicit proof links resolve identity; inference never grants.
|
|
2
|
+
import { isAdmin } from './auth.mjs';
|
|
3
|
+
import { exact } from './request-json.mjs';
|
|
4
|
+
|
|
5
|
+
const fail = (status, code, message) => { throw Object.assign(new Error(message), {status, code}); };
|
|
6
|
+
const guard = (sql, params, message = 'identity authority changed') => ({sql, params, status:403, code:'forbidden', message});
|
|
7
|
+
const text = (value, field, max=1024) => {
|
|
8
|
+
if(typeof value !== 'string' || !value.trim() || value.length>max || /[\x00-\x1f]/.test(value)) fail(400,'invalid_request',`${field} needs a nonempty string of at most ${max} characters`);
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
const observation = value => {
|
|
12
|
+
text(value,'observation');
|
|
13
|
+
if(!/^[a-z][a-z0-9_-]*:.+/.test(value)) fail(400,'invalid_request','an observation must be namespaced');
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
const evidence = value => {
|
|
17
|
+
if(!value || typeof value!=='object' || Array.isArray(value) || Object.keys(value).length===0 || JSON.stringify(value).length>8192) fail(400,'invalid_request','evidence needs a nonempty object of at most 8192 characters');
|
|
18
|
+
return value;
|
|
19
|
+
};
|
|
20
|
+
export function identityAuthority(auth, write=false, proof=false) {
|
|
21
|
+
if(!auth?.active || !['user','service'].includes(auth.kind) || auth.app_grants!=null) fail(403,'forbidden','identity records require an unfenced member credential');
|
|
22
|
+
if(proof && !(isAdmin(auth) && !auth.installation_id) && !(auth.installation_id && auth.app_actions.includes('identity.link'))) fail(403,'forbidden','a proof requires an admin or a consented identity integration');
|
|
23
|
+
if(write && auth.role==='viewer' && !auth.installation_id) fail(403,'forbidden','a viewer cannot write identity evidence');
|
|
24
|
+
}
|
|
25
|
+
function liveGuards(auth, now, admin=false) {
|
|
26
|
+
return [guard('SELECT 1 FROM credentials WHERE id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',[auth.credential_id,now]),
|
|
27
|
+
guard(`SELECT 1 FROM memberships WHERE team_id=? AND principal_id=? AND active=1${admin?" AND role IN ('owner','admin')":''}`,[auth.team_id,auth.principal_id])];
|
|
28
|
+
}
|
|
29
|
+
const principalGuard = (auth,id) => guard(`SELECT 1 FROM principals p WHERE p.id=? AND
|
|
30
|
+
(EXISTS (SELECT 1 FROM memberships m WHERE m.team_id=? AND m.principal_id=p.id AND m.active=1)
|
|
31
|
+
OR EXISTS (SELECT 1 FROM identity_principals ip WHERE ip.team_id=? AND ip.principal_id=p.id))`,[id,auth.team_id,auth.team_id],'principal is outside this Org');
|
|
32
|
+
function linkJson(row) {
|
|
33
|
+
return {id:row.id,observation:row.observation_id,principal_id:row.principal_id,classification:row.classification,
|
|
34
|
+
label:row.classification==='inferred'?'probably':'proven',proof:{kind:row.proof_kind,evidence:JSON.parse(row.evidence_json)},
|
|
35
|
+
created_by:row.created_by,created_at:row.created_at,revision:row.revision,revoked_at:row.revoked_at,revoked_by:row.revoked_by};
|
|
36
|
+
}
|
|
37
|
+
const liveLink = `l.revoked_at IS NULL AND (l.installation_id IS NULL OR EXISTS
|
|
38
|
+
(SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id
|
|
39
|
+
WHERE i.id=l.installation_id AND i.state='active' AND a.state='active' AND installer.active=1 AND installer.role IN ('owner','admin')))`;
|
|
40
|
+
/** This read supplies attribution only. No credential, membership, grant or signature is created. */
|
|
41
|
+
export async function resolveIdentity(api, team, id) {
|
|
42
|
+
observation(id);
|
|
43
|
+
const seen=await api.store.one('SELECT * FROM identity_observations WHERE team_id=? AND id=?',[team,id]);
|
|
44
|
+
const links=await api.store.query(`SELECT l.*,p.display_name,p.kind FROM identity_links l JOIN principals p ON p.id=l.principal_id WHERE l.team_id=? AND l.observation_id=? AND ${liveLink} ORDER BY CASE WHEN l.classification='proven' THEN 0 ELSE 1 END,l.created_at,l.id LIMIT 51`,[team,id]);
|
|
45
|
+
const visible=links.slice(0,50);
|
|
46
|
+
// A session's mailbox is its machine's custodian's: Teams authenticated that custodian when the machine enrolled,
|
|
47
|
+
// so `mailbox:sc:<machine>:…` is proven by custody when exactly one live machine of the team bears that name.
|
|
48
|
+
const box=/^mailbox:sc:([^:]+):/.exec(id);
|
|
49
|
+
if(box&&!visible.some(l=>l.classification==='proven')){
|
|
50
|
+
const machines=await api.store.query('SELECT m.id,m.custodian_id,p.display_name,p.kind FROM machines m JOIN principals p ON p.id=m.custodian_id WHERE m.team_id=? AND m.name=? AND m.revoked_at IS NULL',[team,box[1]]);
|
|
51
|
+
if(machines.length===1){const m=machines[0];visible.unshift({id:`custody:${m.id}`,observation_id:id,principal_id:m.custodian_id,classification:'proven',proof_kind:'custodian',evidence_json:JSON.stringify({machine_id:m.id}),created_by:null,created_at:null,revision:0,revoked_at:null,revoked_by:null,display_name:m.display_name,kind:m.kind,custody:box[1]});links.unshift(visible[0]);}
|
|
52
|
+
}
|
|
53
|
+
const proven=visible.find(l=>l.classification==='proven');
|
|
54
|
+
return {observation:id,first_seen:seen?.first_seen??null,last_seen:seen?.last_seen??null,
|
|
55
|
+
principal:proven?{id:proven.principal_id,name:proven.display_name,kind:proven.kind}:null,
|
|
56
|
+
classification:proven?'proven':links.length?'inferred':'unresolved',
|
|
57
|
+
// the session is still the actor: a custody-derived name says whose machine it runs on, not that it is that person
|
|
58
|
+
label:proven?(proven.custody?`${proven.display_name} (custodian of ${proven.custody})`:proven.display_name):links.length?`probably ${visible.map(l=>l.display_name).join(' or ')}`:'unresolved',
|
|
59
|
+
links:visible.map(linkJson),truncated:links.length>50};
|
|
60
|
+
}
|
|
61
|
+
export async function identityRecordRoute({api,req,res,auth,sub,url,requestId,parseJson,send,result,guardedMutation}) {
|
|
62
|
+
if(!sub.startsWith('/identity/')) return false;
|
|
63
|
+
const write=req.method!=='GET'; identityAuthority(auth,write);
|
|
64
|
+
const now=api.clock.nowMs();
|
|
65
|
+
if(req.method==='GET' && sub==='/identity/resolve') return send(res,200,result(api,auth,await resolveIdentity(api,auth.team_id,url.searchParams.get('observation'))),requestId),true;
|
|
66
|
+
if(req.method==='GET' && sub==='/identity/observations') {
|
|
67
|
+
const limit=Number(url.searchParams.get('limit')??100),after=url.searchParams.get('after')??'';
|
|
68
|
+
if(!Number.isInteger(limit)||limit<1||limit>200) fail(400,'invalid_request','limit must be 1..200');
|
|
69
|
+
const rows=await api.store.query('SELECT * FROM identity_observations WHERE team_id=? AND id>? ORDER BY id LIMIT ?',[auth.team_id,after,limit+1]);
|
|
70
|
+
return send(res,200,{version:1,items:rows.slice(0,limit),next_after:rows.length>limit?rows[limit-1].id:null},requestId),true;
|
|
71
|
+
}
|
|
72
|
+
if(req.method==='GET' && sub==='/identity/sightings') {
|
|
73
|
+
const id=observation(url.searchParams.get('observation')),limit=Number(url.searchParams.get('limit')??100),after=url.searchParams.get('after')??'';
|
|
74
|
+
if(!Number.isInteger(limit)||limit<1||limit>200) fail(400,'invalid_request','limit must be 1..200');
|
|
75
|
+
const rows=await api.store.query('SELECT * FROM identity_sightings WHERE team_id=? AND observation_id=? AND id>? ORDER BY id LIMIT ?',[auth.team_id,id,after,limit+1]);
|
|
76
|
+
return send(res,200,{version:1,items:rows.slice(0,limit).map(r=>({...r,evidence:JSON.parse(r.evidence_json),evidence_json:undefined})),next_after:rows.length>limit?rows[limit-1].id:null},requestId),true;
|
|
77
|
+
}
|
|
78
|
+
if(req.method==='POST' && sub==='/identity/observations') {
|
|
79
|
+
const body=await parseJson(req); exact(body,['observation','location','at','name','evidence']);
|
|
80
|
+
const id=observation(body.observation),location=text(body.location,'location');
|
|
81
|
+
if(!Number.isSafeInteger(body.at)||body.at<0) fail(400,'invalid_request','at must be an epoch millisecond timestamp');
|
|
82
|
+
if(body.name!=null)text(body.name,'name',256);
|
|
83
|
+
const sighting=api.ids.next('sighting');
|
|
84
|
+
await guardedMutation(api,res,auth,liveGuards(auth,now),[
|
|
85
|
+
{sql:'INSERT INTO identity_observations(team_id,id,first_seen,last_seen) VALUES (?,?,?,?) ON CONFLICT(team_id,id) DO UPDATE SET first_seen=MIN(first_seen,excluded.first_seen),last_seen=MAX(last_seen,excluded.last_seen)',params:[auth.team_id,id,body.at,body.at]},
|
|
86
|
+
{sql:'INSERT INTO identity_sightings(team_id,id,observation_id,location,observed_at,recorded_at,recorder_id,name,evidence_json) VALUES (?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sighting,id,location,body.at,now,auth.principal_id,body.name??null,JSON.stringify(evidence(body.evidence))]},
|
|
87
|
+
],{status:201,body:result(api,auth,{id:sighting,observation:id}),targetId:sighting});
|
|
88
|
+
return send(res,201,result(api,auth,{id:sighting,observation:id}),requestId),true;
|
|
89
|
+
}
|
|
90
|
+
if(req.method==='POST' && sub==='/identity/principals') {
|
|
91
|
+
if(!isAdmin(auth)||auth.installation_id)fail(403,'forbidden','only an Org admin declares a person');
|
|
92
|
+
const body=await parseJson(req);exact(body,['name']);const name=text(body.name,'name',256),id=api.ids.next('user');
|
|
93
|
+
const response=result(api,auth,{id,kind:'user',display_name:name});
|
|
94
|
+
await guardedMutation(api,res,auth,liveGuards(auth,now,true),[
|
|
95
|
+
{sql:'INSERT INTO principals(id,kind,display_name) VALUES (?,?,?)',params:[id,'user',name]},
|
|
96
|
+
{sql:'INSERT INTO identity_principals(team_id,principal_id,declared_by,created_at) VALUES (?,?,?,?)',params:[auth.team_id,id,auth.principal_id,now]},
|
|
97
|
+
],{status:201,body:response,targetId:id});return send(res,201,response,requestId),true;
|
|
98
|
+
}
|
|
99
|
+
if(req.method==='POST' && (sub==='/identity/links'||sub==='/identity/inferences')) {
|
|
100
|
+
const body=await parseJson(req);exact(body,['observation','principal_id','classification','proof']);
|
|
101
|
+
if(sub==='/identity/inferences'&&body.classification!=='inferred')fail(400,'invalid_request','an inference never accepts proven classification');
|
|
102
|
+
const proofGuards=[];
|
|
103
|
+
const id=api.ids.next('identity_link'),observed=observation(body.observation),pid=text(body.principal_id,'principal_id',128);
|
|
104
|
+
if(!['proven','inferred'].includes(body.classification))fail(400,'invalid_request','classification must be proven or inferred');
|
|
105
|
+
exact(body.proof,['kind','evidence']);const proof=evidence(body.proof.evidence),kind=text(body.proof.kind,'proof.kind',64),proven=body.classification==='proven';
|
|
106
|
+
if(proven){
|
|
107
|
+
identityAuthority(auth,true,true);
|
|
108
|
+
if(!['pairing','integration','custodian','identity_subject'].includes(kind))fail(400,'invalid_request','proof kind must name a verified source');
|
|
109
|
+
if(kind==='integration' && !auth.installation_id)fail(403,'forbidden','integration proof requires its installed app');
|
|
110
|
+
if(kind==='pairing' && (auth.installation_id||!isAdmin(auth)))fail(403,'forbidden','pairing proof requires the approving Org admin');
|
|
111
|
+
// A pairing is a code the person was given and the admin approved: an admin's word alone is not one.
|
|
112
|
+
if(kind==='pairing' && !(typeof proof.code_digest==='string'&&/^[a-f0-9]{64}$/.test(proof.code_digest)))fail(400,'invalid_request','pairing proof carries the digest of the code that was exchanged');
|
|
113
|
+
if(kind==='identity_subject'){
|
|
114
|
+
const known=await api.store.one('SELECT principal_id FROM principal_identities WHERE issuer=? AND subject=?',[text(proof.issuer,'issuer'),text(proof.subject,'subject')]);
|
|
115
|
+
proofGuards.push(guard('SELECT 1 FROM principal_identities WHERE issuer=? AND subject=? AND principal_id=?',[proof.issuer,proof.subject,pid],'identity subject changed'));
|
|
116
|
+
if(known?.principal_id!==pid)fail(403,'forbidden','the signed-in identity subject does not prove this principal');
|
|
117
|
+
if(observed!==`oidc:${proof.issuer}:${proof.subject}`)fail(400,'invalid_request','identity-subject observation must exactly name that issuer and subject');
|
|
118
|
+
}
|
|
119
|
+
if(kind==='custodian'){
|
|
120
|
+
const known=await api.store.one('SELECT custodian_id FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL',[auth.team_id,proof.machine_id]);
|
|
121
|
+
proofGuards.push(guard('SELECT 1 FROM machines WHERE team_id=? AND id=? AND revoked_at IS NULL AND custodian_id=?',[auth.team_id,proof.machine_id,pid],'machine custodian changed'));
|
|
122
|
+
if(known?.custodian_id!==pid || observed!==`machine:${proof.machine_id}`)fail(403,'forbidden','this machine does not prove that custodian');
|
|
123
|
+
}
|
|
124
|
+
}else if(kind!=='inference')fail(400,'invalid_request','inferred evidence must use kind inference');
|
|
125
|
+
const known=await api.store.one(principalGuard(auth,pid).sql,principalGuard(auth,pid).params);
|
|
126
|
+
if(!known)fail(404,'not_found','principal is outside this Org');
|
|
127
|
+
if(proven&&kind==='pairing') {
|
|
128
|
+
const person=await api.store.one("SELECT 1 FROM principals WHERE id=? AND kind='user'",[pid]);
|
|
129
|
+
if(!person)fail(400,'invalid_request','a paired person must resolve to a person principal');
|
|
130
|
+
proofGuards.push(guard("SELECT 1 FROM principals WHERE id=? AND kind='user'",[pid],'paired principal kind changed'));
|
|
131
|
+
}
|
|
132
|
+
const old=proven?await api.store.one(`SELECT l.principal_id FROM identity_links l WHERE l.team_id=? AND l.observation_id=? AND l.classification='proven' AND l.revoked_at IS NULL`,[auth.team_id,observed]):null;
|
|
133
|
+
if(old)fail(409,'conflict','revoke the existing proven link before creating another');
|
|
134
|
+
const row={id,observation_id:observed,principal_id:pid,classification:body.classification,proof_kind:kind,evidence_json:JSON.stringify(proof),created_by:auth.principal_id,created_at:now,revision:1,revoked_at:null,revoked_by:null};
|
|
135
|
+
const response=result(api,auth,linkJson(row));
|
|
136
|
+
await guardedMutation(api,res,auth,[...liveGuards(auth,now,proven&&!auth.installation_id),principalGuard(auth,pid),...proofGuards,...(proven?[guard("SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM identity_links WHERE team_id=? AND observation_id=? AND classification='proven' AND revoked_at IS NULL)",[auth.team_id,observed],'a proven link already exists')]:[])],[
|
|
137
|
+
{sql:'INSERT INTO identity_observations(team_id,id,first_seen,last_seen) VALUES (?,?,?,?) ON CONFLICT(team_id,id) DO NOTHING',params:[auth.team_id,observed,now,now]},
|
|
138
|
+
{sql:'INSERT INTO identity_links(team_id,id,observation_id,principal_id,classification,proof_kind,evidence_json,created_by,created_at,installation_id) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,id,observed,pid,body.classification,kind,JSON.stringify(proof),auth.principal_id,now,auth.installation_id??null]},
|
|
139
|
+
],{status:201,body:response,targetId:id});return send(res,201,response,requestId),true;
|
|
140
|
+
}
|
|
141
|
+
const match=/^\/identity\/(links|inferences)\/([^/]+)$/.exec(sub);
|
|
142
|
+
if(req.method==='DELETE' && match){
|
|
143
|
+
const row=await api.store.one('SELECT * FROM identity_links WHERE team_id=? AND id=?',[auth.team_id,match[2]]);
|
|
144
|
+
if(row&&match[1]==='inferences'&&row.classification!=='inferred')fail(404,'not_found','inference not found');
|
|
145
|
+
if(!row)fail(404,'not_found','identity link not found');
|
|
146
|
+
const admin=isAdmin(auth)&&!auth.installation_id;
|
|
147
|
+
if(!admin&&row.created_by!==auth.principal_id)fail(403,'forbidden','only the author or an Org admin may revoke a link');
|
|
148
|
+
const revision=Number(req.headers['if-match']);if(!Number.isSafeInteger(revision)||revision!==row.revision)fail(409,'revision_mismatch','If-Match must name the current link revision');
|
|
149
|
+
await guardedMutation(api,res,auth,[...liveGuards(auth,now,admin),guard('SELECT 1 FROM identity_links WHERE team_id=? AND id=? AND revision=?',[auth.team_id,row.id,row.revision],'identity link changed')],[{sql:'UPDATE identity_links SET revoked_at=COALESCE(revoked_at,?),revoked_by=COALESCE(revoked_by,?),revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.principal_id,auth.team_id,row.id]}],{status:204,body:null,targetId:row.id});return send(res,204,null,requestId),true;
|
|
150
|
+
}
|
|
151
|
+
fail(404,'not_found','identity record operation not found');
|
|
152
|
+
}
|
package/server/identity.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export async function identityOf(api, token) {
|
|
|
14
14
|
const claims=JSON.parse(Buffer.from(token.split('.')[1]??'', 'base64url').toString('utf8'));
|
|
15
15
|
if(typeof claims.iss!=='string'||!await installedIssuer(api,claims.iss,'identity.login'))return null;
|
|
16
16
|
const person=await verifyIdentityToken(token,{issuer:claims.iss,audience:api.publicOrigin});
|
|
17
|
-
|
|
17
|
+
// The verified token's `groups` claim (OIDC): the issuer's groups this person belongs to, which a team may admit.
|
|
18
|
+
return {issuer:claims.iss,subject:person.subject,email:person.email??null,name:person.name??null,groups:Array.isArray(claims.groups)?claims.groups.filter(group=>typeof group==='string'):[]};
|
|
18
19
|
} catch { return null; }
|
|
19
20
|
}
|